""" TEE OFF BACKEND API v3.6.9 - KOBLET PÅ ADMIN KJØR-KNAPP --------------------------------------------------------------------------- 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. LOV: Aldri trunker eller slett logikk for "effektivitet". --------------------------------------------------------------------------- """ from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks 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 # NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER from pydantic import BaseModel from typing import Optional, List import subprocess load_dotenv() # --- 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") # --- PYDANTIC MODELLER --- class ScrapeSettingsUpdate(BaseModel): scrape_method: Optional[str] = None scrape_status_url: Optional[str] = None scrape_status_selector: Optional[str] = None # NY MODELL FOR Å TA IMOT IDER FOR SCRAPING class ScrapeRunRequest(BaseModel): facility_ids: List[int] # --- FUNKSJONER --- def format_row(row): """ Vasker data fra databasen: 1. Konverterer datoer til ISO-format. 2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister. 3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj. """ if row is None: return None d = dict(row) for key in ['status_updated_at', 'created_at']: if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat() json_list_fields = [ 'course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes' ] json_dict_fields = [ 'amenities', 'vtg', 'nsg_data', 'golfamore_data' ] for field in json_list_fields: if field in d: val = d[field] if val is None: d[field] = [] elif isinstance(val, str): try: d[field] = json.loads(val) except: d[field] = [] elif not isinstance(val, list): d[field] = [] for field in json_dict_fields: if field in d: val = d[field] if val is None: d[field] = {} elif isinstance(val, str): try: d[field] = json.loads(val) except: d[field] = {} elif not isinstance(val, dict): d[field] = {} return d # --- BAKGRUNNSARBEIDER: FUNKSJON SOM KJØRER SKRAPEREN I BAKGRUNNEN --- def run_scrape_worker(facility_ids: List[int]): """ Kjører selve skraping-scriptet i bakgrunnen. Slik kan frontenden få et umiddelbart svar, mens skraperen jobber. """ print(f"🔄 STARTER BAKGRUNNSSKRAPING FOR FØLGENDE IDER: {facility_ids}") # Her kjører vi skraping-scriptet ditt via et system-kall (subprocess) # Dette er den tryggeste måten å starte et annet script på uten å forstyrre API-et. try: # Konverterer listen med IDer til en streng som vi kan sende som argument ids_arg = ",".join(map(str, facility_ids)) # Vi antar at scrape_status.py ligger i samme mappe som main.py # Slett /dev/null hvis du vil ha logg-utskrifter i terminalen. command = f"python scrape_status.py --ids {ids_arg} > /dev/null 2>&1" subprocess.run(command, shell=True, check=True) print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}") except subprocess.CalledProcessError as e: print(f"❌ FEIL UNDER BAKGRUNNSSKRAPING: {e}") except Exception as e: print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}") @asynccontextmanager async def lifespan(app: FastAPI): # Opprett database-pool ved start try: print(f"📡 Forsøker å koble til database på: {DB_URL}") app.state.pool = await asyncpg.create_pool( DB_URL, min_size=5, max_size=20, command_timeout=60 ) print("✅ Database tilkoblet og pool opprettet") except Exception as e: print(f"❌ Databasefeil under oppstart: {e}") raise e yield # Lukk pool ved avslutning await app.state.pool.close() app = FastAPI(title="TeeOff API v3.6.9", lifespan=lifespan) # CORS - Tillater både lokal utvikling og produksjonsdomene app.add_middleware( CORSMiddleware, allow_origins=[ "https://nye.teeoff.no", "http://nye.teeoff.no", "http://localhost:3000" ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- AUTH ENDPOINTS --- @app.post("/api/auth/login") async def login(data: dict): """Steg 1: Sjekk passord og returner temp_token for 2FA.""" print(f"🔐 Loggin-forsøk for: {data.get('username')}") 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: print(" - ❌ Bruker ikke funnet i databasen") raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") h = admin['password_hash'] print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)") try: is_valid = pwd_context.verify(data.get('password'), h) except Exception as e: print(f" - 🔥 FEIL VED LESING AV HASH: {e}") raise HTTPException(status_code=500, detail="Internt problem med passord-format") if not is_valid: print(" - ❌ Passordet samsvarer ikke med hashen") 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 ) print(" - ✅ Steg 1 fullført. Temp-token generert.") return {"step": "2fa", "temp_token": temp_token} @app.post("/api/auth/verify-2fa") async def verify_2fa(data: dict, response: Response): """Steg 2: Verifiser TOTP-kode og sett session cookie.""" try: payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM]) if not payload.get("partial"): raise JWTError() username = payload.get("sub") except JWTError: raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig") 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')): print(f" - ❌ Feil 2FA-kode oppgitt for {username}") 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 ) # Sett som HTTP-only cookie response.set_cookie( key="admin_session", value=final_token, httponly=True, samesite="lax", secure=False # Sett til True i produksjon (HTTPS) ) return {"status": "success"} # --- DATA ENDPOINTS --- @app.get("/api/facilities") async def get_facilities(): """Henter alle golfanlegg med aggregert banestatus for forsiden.""" async with app.state.pool.acquire() as conn: rows = await conn.fetch(""" SELECT f.*, ( SELECT jsonb_agg(cs) FROM ( SELECT name, status FROM courses WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' ORDER BY is_main_course DESC, id ASC ) cs ) as course_statuses FROM facilities f ORDER BY f.name ASC """) return [format_row(row) for row in rows] @app.get("/api/facilities/{slug}") async def get_facility(slug: str): """Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull.""" 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 AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent'))) ORDER BY c.is_main_course DESC, c.id ASC ) c_data ) as courses FROM facilities f WHERE f.slug = $1 """, slug) if not row: raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") return format_row(row) # --- ADMIN ENDPOINTS --- @app.patch("/api/admin/facilities/{facility_id}/scrape-settings") async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate): """Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL).""" async with app.state.pool.acquire() as conn: try: # Sjekk først at anlegget eksisterer facility = await conn.fetchrow("SELECT id FROM facilities WHERE id = $1", facility_id) if not facility: raise HTTPException(status_code=404, detail="Anlegget finnes ikke.") # Oppdater verdiene i databasen await conn.execute(""" UPDATE facilities SET scrape_method = $1, scrape_status_url = $2, scrape_status_selector = $3 WHERE id = $4 """, settings.scrape_method, settings.scrape_status_url, settings.scrape_status_selector, facility_id) return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."} except Exception as e: if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail=str(e)) # --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER --- @app.post("/api/admin/run-scraper") async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks): """ Tar imot IDer for skraping, og starter en bakgrunnsjobb. Gir et umiddelbart svar tilbake til frontenden slik at den slipper å vente. """ if not request.facility_ids: raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}") # Her starter vi selve magien: Vi legger jobben i FastAPIs BackgroundTasks background_tasks.add_task(run_scrape_worker, request.facility_ids) return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."} @app.get("/api/health") async def health_check(): """Enkel sjekk for å se at API og DB lever.""" try: async with app.state.pool.acquire() as conn: await conn.execute("SELECT 1") return {"status": "healthy", "database": "connected"} except Exception as e: return {"status": "unhealthy", "error": str(e)} if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)