Nye-TeeOff/backend/main.py

195 lines
6.8 KiB
Python
Raw Normal View History

"""
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
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 ---
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.
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)
# 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()
# 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]
if val is None: d[field] = []
2026-02-27 09:35:30 +01:00
elif isinstance(val, str):
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]
if val is None: d[field] = {}
2026-02-27 09:35:30 +01:00
elif isinstance(val, str):
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):
# 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(
DB_URL, min_size=5, max_size=20, command_timeout=60
2026-02-27 09:35:30 +01:00
)
print("✅ Database pool opprettet")
2026-02-27 08:53:14 +01:00
except Exception as e:
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()
app = FastAPI(title="TeeOff API v3.6.5", lifespan=lifespan)
2026-02-27 08:53:14 +01:00
# CORS - Tillater både lokal utvikling og produksjonsdomene
2026-02-27 08:53:14 +01:00
app.add_middleware(
CORSMiddleware,
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
# --- 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():
"""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):
"""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:
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():
return {"status": "healthy"}