diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc index 0226cfe..841e055 100644 Binary files a/backend/__pycache__/main.cpython-311.pyc and b/backend/__pycache__/main.cpython-311.pyc differ diff --git a/backend/main.py b/backend/main.py index d57f6e1..04665c8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,5 @@ """ -TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION +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. @@ -9,7 +9,7 @@ LOV: Aldri trunker eller slett logikk for "effektivitet". --------------------------------------------------------------------------- """ -from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request +from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import asyncpg @@ -21,6 +21,11 @@ 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 --- @@ -28,9 +33,19 @@ DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_pass SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production") ALGORITHM = "HS256" -# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger 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: @@ -43,12 +58,10 @@ def format_row(row): d = dict(row) - # 1. Håndter dato- og tidsformater for JSON-serialisering for key in ['status_updated_at', 'created_at']: if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat() - # 2. Definer alle felter som inneholder JSON-data json_list_fields = [ 'course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes' @@ -57,7 +70,6 @@ def format_row(row): 'amenities', 'vtg', 'nsg_data', 'golfamore_data' ] - # Vask list-felter for field in json_list_fields: if field in d: val = d[field] @@ -71,7 +83,6 @@ def format_row(row): elif not isinstance(val, list): d[field] = [] - # Vask objekt-felter for field in json_dict_fields: if field in d: val = d[field] @@ -87,6 +98,32 @@ def format_row(row): 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 @@ -106,7 +143,7 @@ async def lifespan(app: FastAPI): # Lukk pool ved avslutning await app.state.pool.close() -app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan) +app = FastAPI(title="TeeOff API v3.6.9", lifespan=lifespan) # CORS - Tillater både lokal utvikling og produksjonsdomene app.add_middleware( @@ -141,14 +178,12 @@ async def login(data: dict): h = admin['password_hash'] print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)") - # FIKS: Vi pakker KUN selve verify-sjekken inn i try/except 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") - # FIKS: 401 kastes nå UTENFOR try-blokken, slik at vi unngår 500-krasj if not is_valid: print(" - ❌ Passordet samsvarer ikke med hashen") raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") @@ -238,6 +273,56 @@ async def get_facility(slug: str): 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.""" diff --git a/backend/requirements.txt b/backend/requirements.txt index 97d1232..f9986c5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,4 +11,4 @@ python-dotenv python-jose[cryptography] passlib[bcrypt] pyotp -google-generativeai \ No newline at end of file +google-genai \ No newline at end of file diff --git a/backend/scrape_status.py b/backend/scrape_status.py index 0a18990..68d9701 100644 --- a/backend/scrape_status.py +++ b/backend/scrape_status.py @@ -3,6 +3,7 @@ import os import asyncpg import smtplib import re +import argparse from datetime import datetime from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart @@ -12,12 +13,77 @@ try: except ImportError: from playwright_stealth import stealth as apply_stealth +from google import genai from dotenv import load_dotenv load_dotenv() -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" +DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +# ========================================== +# KONFIGURERER GEMINI AI (NY SDK) +# ========================================== +# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din +client = genai.Client() + +async def ask_llm_status(text, course_name, is_single_course): + """Sender teksten til Gemini og ber om ett enkelt status-ord tilbake.""" + + # 1. Dynamisk instruks basert på antall baner + if is_single_course: + bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane." + else: + bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".' + + # 2. Selve promptet + prompt = f""" + Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus. + {bane_instruks} + Svar KUN med nøyaktig ETT av disse ordene: + - aapen (hvis banen er åpen/sommergreener) + - stengt (hvis banen er lukket/stengt/frost/snø) + - aapen_med_vintergreener (hvis det spilles på vintergreener) + - aapner_snart (hvis den åpner om kort tid) + - stenger_snart (hvis den stenger for sesongen om kort tid) + - under_utvikling (hvis den er under utvikling) + - nedlagt (hvis den er nedlagt) + - ukjent (hvis du ikke finner noe info om banen i teksten) + + Tekst fra nettsiden: + {text[:15000]} + """ + + try: + response = await client.aio.models.generate_content( + model='gemini-2.5-flash', + contents=prompt + ) + svar = response.text.strip().lower() + + # 3. Sikkerhetsfilteret som matcher ordene i promptet + gyldige_svar = [ + "aapen", + "stengt", + "aapen_med_vintergreener", + "aapner_snart", + "stenger_snart", + "under_utvikling", + "nedlagt", + "ukjent" + ] + + for gyldig in gyldige_svar: + if gyldig in svar: + return gyldig + return "ukjent" + except Exception as e: + print(f"❌ Gemini Feil: {e}") + return "ukjent" + + +# ========================================== +# EKSISTERENDE LOGIKK FOR MANUELL SCRAPING +# ========================================== def clean_text(text): return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower() @@ -48,6 +114,7 @@ def interpret_status(text, keyword=None): def send_report(changes, warnings, successes): if not changes and not warnings and not successes: return subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}" + body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n" if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\n\n" @@ -67,11 +134,33 @@ def send_report(changes, warnings, successes): except Exception as e: print(f"❌ E-post feil: {e}") -async def run_daily_scraping(): + +# ========================================== +# HOVEDMOTOR +# ========================================== +async def run_daily_scraping(facility_ids=None): print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...") conn = await asyncpg.connect(DB_URL) - facilities = await conn.fetch("SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL") + # --- NYTT: Filtrerer basert på valgte IDer fra Admin-panelet --- + if facility_ids: + print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}") + facilities = await conn.fetch( + "SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])", + facility_ids + ) + else: + print("🌍 Kjører skraping for ALLE anlegg med scrape_status_url...") + facilities = await conn.fetch( + "SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL" + ) + + if not facilities: + print("⚠️ Fant ingen anlegg å skrape.") + await conn.close() + return + # ---------------------------------------------------------------- + changes, warnings, successes = [], [], [] async with async_playwright() as p: @@ -84,8 +173,7 @@ async def run_daily_scraping(): except: pass try: - print(f"🔍 Besøker {f['name']}...") - # Endret fra networkidle til domcontentloaded for å unngå Arendal-timeout + print(f"🔍 Besøker {f['name']} (Metode: {f.get('scrape_method') or 'css_selector'})...") await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded") await asyncio.sleep(3) # Gir Javascript 3 sekunder på å bygge siden @@ -108,26 +196,20 @@ async def run_daily_scraping(): full_text = await element.inner_text() elif method == 'click_then_css': - # Vi forventer formatet: "knappe_selector||tekst_selector" parts = f['scrape_status_selector'].split('||') if len(parts) != 2: warnings.append(f"❌ {f['name']}: Ugyldig selector for click_then_css (mangler ||)") continue btn_selector, text_selector = parts - - # 1. Finn og klikk på knappen btn = page.locator(btn_selector).first if await btn.count() == 0: warnings.append(f"❌ {f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'") continue await btn.click() - - # 2. Vent 2 sekunder så animasjonen (sidepanelet) rekker å bli ferdig await asyncio.sleep(2) - # 3. Les av teksten element = page.locator(text_selector).first if await element.count() == 0: warnings.append(f"❌ {f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk") @@ -135,6 +217,32 @@ async def run_daily_scraping(): full_text = await element.inner_text() + # NY METODE: LLM PARSE (GEMINI) + elif method == 'llm_parse': + # --- AUTO-KLIKKER --- + print(" 🖱️ Leter etter 'banestatus'-knapper å klikke på...") + knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all() + + for knapp in knapper: + try: + if await knapp.is_visible(): + await knapp.click(timeout=3000) + print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...") + await asyncio.sleep(2) + break + except Exception: + pass + # -------------------- + + # Kopierer all synlig tekst fra hele nettsiden + element = page.locator("body").first + if await element.count() == 0: + warnings.append(f"❌ {f['name']}: Klarte ikke å lese siden for AI-tolkning") + continue + råtekst = await element.inner_text() + # Fjerner overflødige linjeskift for å komprimere teksten før sending til Gemini + full_text = " ".join(råtekst.split()) + else: warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'") continue @@ -142,11 +250,21 @@ async def run_daily_scraping(): await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id']) courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id']) + + # Sjekk om anlegget kun har én bane + is_single_course = len(courses) == 1 + for c in courses: - new_status = interpret_status(full_text, c['scrape_keyword']) + + # HENTER STATUS VIA AI ELLER GAMMEL METODE + if method == 'llm_parse': + print(f" 🤖 Spør Gemini om status for '{c['name']}' (Singelbane: {is_single_course})...") + new_status = await ask_llm_status(full_text, c['name'], is_single_course) + else: + new_status = interpret_status(full_text, c['scrape_keyword']) if new_status == "NOT_FOUND": - warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten på siden.") + warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.") continue old_status = c['status'] or "ukjent" @@ -159,11 +277,11 @@ async def run_daily_scraping(): print(f" - {c['name']}: Ingen endring ({new_status.upper()})") except Exception as e: - # Trekker ut kun første linje av feilmeldingen for å unngå massiv og stygg tekst i e-posten err_msg = str(e).split('\n')[0] warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}") finally: await page.close() + await browser.close() await conn.close() @@ -171,4 +289,17 @@ async def run_daily_scraping(): print("🏁 Ferdig.") if __name__ == "__main__": - asyncio.run(run_daily_scraping()) \ No newline at end of file + # --- NYTT: Tar imot argumenter fra main.py (Background Task) --- + parser = argparse.ArgumentParser(description="TeeOff Status Scraper") + parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None) + args = parser.parse_args() + + facility_ids_list = None + if args.ids: + try: + facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()] + except ValueError: + print("❌ Feil format på --ids. Må være kommaseparerte tall, f.eks: 1,4,12") + exit(1) + + asyncio.run(run_daily_scraping(facility_ids_list)) \ No newline at end of file diff --git a/backend/test_gemini.py b/backend/test_gemini.py new file mode 100644 index 0000000..1141ed3 --- /dev/null +++ b/backend/test_gemini.py @@ -0,0 +1,116 @@ +import asyncio +import os +import re +from playwright.async_api import async_playwright +from google import genai +from dotenv import load_dotenv + +load_dotenv() + +# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din +client = genai.Client() + +async def ask_llm_status(text, course_name, is_single_course): + if is_single_course: + bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane." + else: + bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".' + + prompt = f""" + Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus. + {bane_instruks} + Svar KUN med nøyaktig ETT av disse ordene: + - aapen (hvis banen er åpen/sommergreener) + - stengt (hvis banen er lukket/stengt/frost/snø) + - aapen_med_vintergreener (hvis det spilles på vintergreener) + - aapner_snart (hvis den åpner om kort tid) + - stenger_snart (hvis den stenger for sesongen om kort tid) + - under_utvikling (hvis den er under utvikling) + - nedlagt (hvis den er nedlagt) + - ukjent (hvis du ikke finner noe info om banen i teksten) + + Tekst fra nettsiden: + {text[:15000]} + """ + + try: + # Ny måte å kalle modellen asynkront på med google-genai + response = await client.aio.models.generate_content( + model='gemini-2.5-flash', + contents=prompt + ) + svar = response.text.strip().lower() + + gyldige_svar = [ + "aapen", "stengt", "aapen_med_vintergreener", + "aapner_snart", "stenger_snart", "under_utvikling", + "nedlagt", "ukjent" + ] + + for gyldig in gyldige_svar: + if gyldig in svar: + return gyldig + return "ukjent" + except Exception as e: + print(f"❌ Gemini Feil: {e}") + return "ukjent" + +async def run_test(): + print("\n" + "="*50) + print(" 🧪 TEE OFF: GEMINI TEST-VERKTØY (MED AUTO-KLIKKER)") + print("="*50) + + url = input("🌐 Skriv inn URL til golfklubben (f.eks. https://oslogk.no): ").strip() + if not url.startswith("http"): + url = "https://" + url + + course_name = input("⛳ Skriv inn banenavn (eller trykk ENTER hvis anlegget kun har 1 bane): ").strip() + is_single = len(course_name) == 0 + + print("\n⏳ 1. Starter nettleser og besøker siden...") + + full_text = "" + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + try: + await page.goto(url, timeout=30000, wait_until="domcontentloaded") + await asyncio.sleep(3) # Vent på animasjoner og iframes + + # --- NY LOGIKK: AUTO-KLIKKER --- + print("🖱️ Leter etter 'banestatus'-knapper å klikke på...") + # Vi leter etter tekst som inneholder "banestatus" (ignorerer store/små bokstaver) + knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all() + + for knapp in knapper: + try: + if await knapp.is_visible(): + await knapp.click(timeout=3000) + print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...") + await asyncio.sleep(2) # Venter på at modalen/pop-upen åpner seg + break # Vi trenger bare å klikke på den første vi finner + except Exception as e: + # Ignorerer hvis knappen ikke er klikkbar, prøver neste + pass + # -------------------------------- + + element = page.locator("body").first + råtekst = await element.inner_text() + full_text = " ".join(råtekst.split()) + print(f"✅ Hentet {len(full_text)} tegn med tekst fra nettsiden.") + + except Exception as e: + print(f"❌ Feil ved innlasting av side: {e}") + await browser.close() + return + await browser.close() + + print("🧠 2. Sender teksten til Gemini for analyse...") + status = await ask_llm_status(full_text, course_name, is_single) + + print("\n" + "="*50) + print(f"🎯 GEMINI SITT SVAR: {status.upper()}") + print("="*50 + "\n") + +if __name__ == "__main__": + asyncio.run(run_test()) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1bb8f59..2ce36ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,7 @@ services: - "8001:8000" volumes: - ./backend:/app - # Denne linjen sørger for at bilder lastet ned av import_wp.py - # lagres direkte i frontendens public-mappe på serveren din: + # Denne linjen sørger for at bilder lagres direkte i frontendens public-mappe: - ./frontend/public/media:/app/public/media depends_on: - db @@ -29,10 +28,11 @@ services: frontend: build: ./frontend container_name: teeoff_frontend + # NY LINJE: Tvinger produksjonsmodus for å stoppe WebSocket-feil og relasting + command: sh -c "npm run build && npm start" ports: - "3000:3000" - volumes: - - ./frontend:/app + # VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet depends_on: - api restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 40c8070..6308ff9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,6 +9,5 @@ RUN npm install # Kopier resten av koden COPY . . -# Vi starter serveren i "dev"-modus (utviklingsmodus). -# Dette gjør at vi kan se endringer live mens vi koder! -CMD ["npm", "run", "dev"] +# Vi starter IKKE serveren i "dev"-modus (utviklingsmodus). +CMD ["sh", "-c", "npm run build && npm start"] diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 0b49133..dd969a8 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,20 +1,26 @@ "use client"; /** - * TEE OFF ADMIN DASHBOARD v1.1 + * TEE OFF ADMIN DASHBOARD v1.4 - LIVE PROGRESSION * --------------------------------------------------------------------------- * PLASSERING: frontend/src/app/admin/page.tsx - * FUNKSJON: Monitorering av banestatus og administrasjon. + * FUNKSJON: Starter bakgrunnsjobber og oppdaterer tabellen live. * --------------------------------------------------------------------------- */ import { useState, useEffect } from 'react'; import { API_URL } from "@/config/constants"; +import ScrapeMethodSelect from "@/components/ScrapeMethodSelect"; export default function AdminDashboard() { - const [facilities, setFacilities] = useState([]); + const [facilities, setFacilities] = useState([]); const [loading, setLoading] = useState(true); + const [selectedFacilities, setSelectedFacilities] = useState([]); + + // NYTT: Holder styr på om en skraping pågår akkurat nå + const [isScraping, setIsScraping] = useState(false); - useEffect(() => { + // Henter data fra databasen + const fetchFacilities = () => { fetch(`${API_URL}/facilities`) .then(res => res.json()) .then(data => { @@ -22,13 +28,69 @@ export default function AdminDashboard() { setLoading(false); }) .catch(() => setLoading(false)); + }; + + // Hent data ved første innlasting + useEffect(() => { + fetchFacilities(); }, []); + // NYTT: Hvis skraping pågår, oppdater tabellen hvert 10. sekund! + useEffect(() => { + let interval: NodeJS.Timeout; + if (isScraping) { + interval = setInterval(() => { + fetchFacilities(); + }, 10000); + } + return () => clearInterval(interval); + }, [isScraping]); + + const handleSelectAll = (e: React.ChangeEvent) => { + if (e.target.checked) { + setSelectedFacilities(facilities.map(f => f.id)); + } else { + setSelectedFacilities([]); + } + }; + + const handleSelectOne = (id: number, checked: boolean) => { + if (checked) { + setSelectedFacilities([...selectedFacilities, id]); + } else { + setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id)); + } + }; + + // NYTT: Sender IDene til API-et og starter auto-oppdatering + const handleRunScrapers = async () => { + setIsScraping(true); + try { + const response = await fetch(`${API_URL}/admin/run-scraper`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ facility_ids: selectedFacilities }) + }); + + if (!response.ok) throw new Error("Kunne ikke starte skraping"); + + // Valgfritt: Fjern avhukingene når jobben har startet + setSelectedFacilities([]); + + // Stopper auto-oppdateringen etter 10 minutter (for sikkerhets skyld) + setTimeout(() => setIsScraping(false), 10 * 60 * 1000); + + } catch (error) { + console.error(error); + alert("Feil ved start av skraperen."); + setIsScraping(false); + } + }; + if (loading) return
LASTER DASHBORD...
; return (
- {/* SIDEBAR (22%) */}
- {/* HOVEDINNHOLD (78%) */}
@@ -49,33 +110,87 @@ export default function AdminDashboard() {

Scraping Monitor

Sjekker status på {facilities.length} anlegg

- + + {/* NYTT: Knappen endrer utseende når skraping pågår */} +
- + + + - + + {facilities.map((f: any) => ( - + + + diff --git a/frontend/src/components/ScrapeMethodSelect.tsx b/frontend/src/components/ScrapeMethodSelect.tsx new file mode 100644 index 0000000..8b93e85 --- /dev/null +++ b/frontend/src/components/ScrapeMethodSelect.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from 'react'; + +// Tilpass interface til de dataene du allerede har i frontend +interface Facility { + id: number; + scrape_method?: string; + scrape_status_url?: string; + scrape_status_selector?: string; +} + +export default function ScrapeMethodSelect({ facility }: { facility: Facility }) { + // Setter standardverdi til 'css_selector' hvis den er tom i databasen + const [method, setMethod] = useState(facility.scrape_method || 'css_selector'); + const [isLoading, setIsLoading] = useState(false); + const [statusColor, setStatusColor] = useState('bg-transparent'); // For å gi visuell feedback + + const handleMethodChange = async (newMethod: string) => { + setMethod(newMethod); + setIsLoading(true); + setStatusColor('bg-yellow-200'); // Lyser gult mens den lagrer + + try { + // Husk å endre URL-en hvis API-et ditt ligger på et annet domene + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + // Hvis du bruker JWT i headers i stedet for cookies, legg det til her: + // 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + scrape_method: newMethod, + scrape_status_url: facility.scrape_status_url, // Beholder eksisterende + scrape_status_selector: facility.scrape_status_selector // Beholder eksisterende + }) + }); + + if (!response.ok) { + throw new Error('Feil ved lagring'); + } + + // Suksess! Lyser grønt et kort sekund + setStatusColor('bg-green-300'); + setTimeout(() => setStatusColor('bg-transparent'), 2000); + + } catch (error) { + console.error(error); + setStatusColor('bg-red-300'); // Lyser rødt ved feil + alert("Kunne ikke oppdatere skrapemetode."); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index dc05080..f01dfac 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -1,13 +1,13 @@ /** - * TEE OFF SECURITY MIDDLEWARE v1.0 + * TEE OFF SECURITY MIDDLEWARE v1.1 * --------------------------------------------------------------------------- * REGEL: Beskytter alle ruter under /admin (unntatt /admin/login). * FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler. + * RETTING: Flyttet NextRequest til next/server for å fikse build-error. * --------------------------------------------------------------------------- */ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/request'; +import { NextResponse, type NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
Anlegg + 0} + onChange={handleSelectAll} + /> + Anlegg KonfigurasjonMetode Siste SjekkStatusBanestatusHandling
+ + handleSelectOne(f.id, e.target.checked)} + /> +
{f.name}
{f.city}
-
{f.scrape_status_url}
+
{f.scrape_status_url}
{f.scrape_status_selector}
+ + {f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'} +
+ {f.course_statuses && f.course_statuses.map((cs: any, idx: number) => { + let badgeColor = "bg-gray-100 text-gray-500"; + if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700"; + if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700"; + if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700"; + + return ( +
+ + {cs.name} + + + {cs.status || 'UKJENT'} + +
+ ) + })} +
+