2026-03-02 09:05:18 +01:00
|
|
|
"""
|
|
|
|
|
TEE OFF BACKEND API v3.6.5 - THE FINAL MASTER VERSION
|
|
|
|
|
---------------------------------------------------------------------------
|
|
|
|
|
REGEL 1: Bruk str (ikke string) for type-hinting.
|
|
|
|
|
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
|
|
|
|
|
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
|
|
|
|
|
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
|
|
|
|
|
---------------------------------------------------------------------------
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request
|
2026-02-26 09:20:51 +01:00
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
import asyncpg
|
|
|
|
|
import json
|
2026-03-02 09:05:18 +01:00
|
|
|
import pyotp
|
|
|
|
|
import os
|
|
|
|
|
from datetime import datetime, date, timedelta
|
|
|
|
|
from jose import jwt, JWTError
|
|
|
|
|
from passlib.context import CryptContext
|
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
|
|
|
|
|
|
load_dotenv()
|
2026-02-26 09:20:51 +01:00
|
|
|
|
2026-02-27 09:35:30 +01:00
|
|
|
# --- KONFIGURASJON ---
|
2026-03-02 09:05:18 +01:00
|
|
|
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|
|
|
|
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
|
|
|
|
|
ALGORITHM = "HS256"
|
|
|
|
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
2026-02-26 09:20:51 +01:00
|
|
|
|
2026-02-27 09:35:30 +01:00
|
|
|
def format_row(row):
|
|
|
|
|
"""
|
|
|
|
|
Vasker data fra databasen:
|
|
|
|
|
1. Konverterer datoer til ISO-format.
|
2026-03-02 09:05:18 +01:00
|
|
|
2. Parser stringified JSON til ekte Python-objekter.
|
2026-02-27 09:35:30 +01:00
|
|
|
"""
|
|
|
|
|
if row is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
d = dict(row)
|
|
|
|
|
|
2026-03-02 09:05:18 +01:00
|
|
|
# 1. Datoer
|
2026-02-27 09:35:30 +01:00
|
|
|
for key in ['status_updated_at', 'created_at']:
|
|
|
|
|
if isinstance(d.get(key), (date, datetime)):
|
|
|
|
|
d[key] = d[key].isoformat()
|
|
|
|
|
|
2026-03-02 09:05:18 +01:00
|
|
|
# 2. JSON-felter (Lister)
|
2026-02-27 09:35:30 +01:00
|
|
|
json_list_fields = [
|
|
|
|
|
'course_statuses', 'courses', 'gallery', 'greenfee',
|
|
|
|
|
'faqs', 'shotzoom', 'social_links', 'holes'
|
|
|
|
|
]
|
|
|
|
|
for field in json_list_fields:
|
|
|
|
|
if field in d:
|
|
|
|
|
val = d[field]
|
2026-03-02 09:05:18 +01:00
|
|
|
if val is None: d[field] = []
|
2026-02-27 09:35:30 +01:00
|
|
|
elif isinstance(val, str):
|
2026-03-02 09:05:18 +01:00
|
|
|
try: d[field] = json.loads(val)
|
|
|
|
|
except: d[field] = []
|
|
|
|
|
elif not isinstance(val, list): d[field] = []
|
|
|
|
|
|
|
|
|
|
# 3. JSON-felter (Objekter)
|
|
|
|
|
json_dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data']
|
2026-02-27 09:35:30 +01:00
|
|
|
for field in json_dict_fields:
|
|
|
|
|
if field in d:
|
|
|
|
|
val = d[field]
|
2026-03-02 09:05:18 +01:00
|
|
|
if val is None: d[field] = {}
|
2026-02-27 09:35:30 +01:00
|
|
|
elif isinstance(val, str):
|
2026-03-02 09:05:18 +01:00
|
|
|
try: d[field] = json.loads(val)
|
|
|
|
|
except: d[field] = {}
|
|
|
|
|
elif not isinstance(val, dict): d[field] = {}
|
2026-02-27 09:35:30 +01:00
|
|
|
|
|
|
|
|
return d
|
|
|
|
|
|
2026-02-26 09:20:51 +01:00
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app: FastAPI):
|
2026-03-02 09:05:18 +01:00
|
|
|
# Opprett database-pool
|
2026-02-27 08:53:14 +01:00
|
|
|
try:
|
2026-02-27 09:35:30 +01:00
|
|
|
app.state.pool = await asyncpg.create_pool(
|
2026-03-02 09:05:18 +01:00
|
|
|
DB_URL, min_size=5, max_size=20, command_timeout=60
|
2026-02-27 09:35:30 +01:00
|
|
|
)
|
2026-03-02 09:05:18 +01:00
|
|
|
print("✅ Database pool opprettet")
|
2026-02-27 08:53:14 +01:00
|
|
|
except Exception as e:
|
2026-03-02 09:05:18 +01:00
|
|
|
print(f"❌ Databasefeil: {e}")
|
2026-02-27 08:53:14 +01:00
|
|
|
raise e
|
2026-02-26 09:20:51 +01:00
|
|
|
yield
|
|
|
|
|
await app.state.pool.close()
|
|
|
|
|
|
2026-03-02 09:05:18 +01:00
|
|
|
app = FastAPI(title="TeeOff API v3.6.5", lifespan=lifespan)
|
2026-02-27 08:53:14 +01:00
|
|
|
|
2026-03-02 09:05:18 +01:00
|
|
|
# CORS - Tillater både lokal utvikling og produksjonsdomene
|
2026-02-27 08:53:14 +01:00
|
|
|
app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
2026-03-02 09:05:18 +01:00
|
|
|
allow_origins=[
|
|
|
|
|
"https://nye.teeoff.no",
|
|
|
|
|
"http://nye.teeoff.no",
|
|
|
|
|
"http://localhost:3000"
|
|
|
|
|
],
|
|
|
|
|
allow_credentials=True,
|
2026-02-27 08:53:14 +01:00
|
|
|
allow_methods=["*"],
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
)
|
2026-02-26 09:20:51 +01:00
|
|
|
|
2026-03-02 09:05:18 +01:00
|
|
|
# --- AUTH ENDPOINTS ---
|
|
|
|
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
|
|
|
async def login(data: dict):
|
|
|
|
|
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
|
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
|
|
|
admin = await conn.fetchrow(
|
|
|
|
|
"SELECT * FROM admins WHERE username = $1 OR email = $1",
|
|
|
|
|
data.get('username')
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not admin or not pwd_context.verify(data.get('password'), admin['password_hash']):
|
|
|
|
|
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
|
|
|
|
|
|
|
|
|
temp_token = jwt.encode(
|
|
|
|
|
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
|
|
|
|
|
SECRET_KEY, algorithm=ALGORITHM
|
|
|
|
|
)
|
|
|
|
|
return {"step": "2fa", "temp_token": temp_token}
|
|
|
|
|
|
|
|
|
|
@app.post("/api/auth/verify-2fa")
|
|
|
|
|
async def verify_2fa(data: dict, response: Response):
|
|
|
|
|
"""Steg 2: Sjekk TOTP og sett session cookie."""
|
|
|
|
|
try:
|
|
|
|
|
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
|
|
|
|
|
username = payload.get("sub")
|
|
|
|
|
except:
|
|
|
|
|
raise HTTPException(status_code=401, detail="Sesjonen har utløpt")
|
|
|
|
|
|
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
|
|
|
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
|
|
|
|
|
totp = pyotp.TOTP(admin['otp_secret'])
|
|
|
|
|
if not totp.verify(data.get('code')):
|
|
|
|
|
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
|
|
|
|
|
|
|
|
|
|
final_token = jwt.encode(
|
|
|
|
|
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
|
|
|
|
|
SECRET_KEY, algorithm=ALGORITHM
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response.set_cookie(
|
|
|
|
|
key="admin_session", value=final_token,
|
|
|
|
|
httponly=True, samesite="lax", secure=False # False for utvikling
|
|
|
|
|
)
|
|
|
|
|
return {"status": "success"}
|
|
|
|
|
|
|
|
|
|
# --- DATA ENDPOINTS ---
|
|
|
|
|
|
2026-02-26 09:20:51 +01:00
|
|
|
@app.get("/api/facilities")
|
|
|
|
|
async def get_facilities():
|
2026-03-02 09:05:18 +01:00
|
|
|
"""Henter alle anlegg med aggregert banestatus for kortene."""
|
2026-02-26 09:20:51 +01:00
|
|
|
async with app.state.pool.acquire() as conn:
|
|
|
|
|
rows = await conn.fetch("""
|
|
|
|
|
SELECT f.*, (
|
|
|
|
|
SELECT jsonb_agg(cs) FROM (
|
|
|
|
|
SELECT name, status FROM courses
|
2026-02-27 08:53:14 +01:00
|
|
|
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
2026-02-26 09:20:51 +01:00
|
|
|
ORDER BY is_main_course DESC, id ASC
|
|
|
|
|
) cs
|
|
|
|
|
) as course_statuses
|
2026-02-27 09:35:30 +01:00
|
|
|
FROM facilities f
|
|
|
|
|
ORDER BY f.name ASC
|
2026-02-26 09:20:51 +01:00
|
|
|
""")
|
|
|
|
|
return [format_row(row) for row in rows]
|
|
|
|
|
|
|
|
|
|
@app.get("/api/facilities/{slug}")
|
|
|
|
|
async def get_facility(slug: str):
|
2026-03-02 09:05:18 +01:00
|
|
|
"""Henter ett anlegg med alle baner og hull (brukes i FacilityDetailView)."""
|
2026-02-26 09:20:51 +01:00
|
|
|
async with app.state.pool.acquire() as conn:
|
|
|
|
|
row = await conn.fetchrow("""
|
|
|
|
|
SELECT f.*, (
|
|
|
|
|
SELECT jsonb_agg(c_data) FROM (
|
|
|
|
|
SELECT c.*, (
|
|
|
|
|
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
|
|
|
|
|
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
|
|
|
|
|
) as holes
|
|
|
|
|
FROM courses c
|
|
|
|
|
WHERE c.facility_id = f.id
|
2026-02-27 08:53:14 +01:00
|
|
|
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
2026-02-26 09:20:51 +01:00
|
|
|
ORDER BY c.is_main_course DESC, c.id ASC
|
|
|
|
|
) c_data
|
|
|
|
|
) as courses
|
|
|
|
|
FROM facilities f WHERE f.slug = $1
|
|
|
|
|
""", slug)
|
2026-02-27 09:35:30 +01:00
|
|
|
|
|
|
|
|
if not row:
|
2026-03-02 09:05:18 +01:00
|
|
|
raise HTTPException(status_code=404, detail="Banen finnes ikke")
|
2026-02-27 09:35:30 +01:00
|
|
|
|
|
|
|
|
return format_row(row)
|
|
|
|
|
|
|
|
|
|
@app.get("/api/health")
|
|
|
|
|
async def health_check():
|
2026-03-02 09:05:18 +01:00
|
|
|
return {"status": "healthy"}
|