diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc index 3831787..ac2b375 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 35b6ee6..c442104 100644 --- a/backend/main.py +++ b/backend/main.py @@ -66,6 +66,19 @@ class QuickEditRequest(BaseModel): field: str value: str +class GreenfeeApproval(BaseModel): + facility_id: int + greenfee: List[dict] + + +class VtgApproval(BaseModel): + facility_id: int + vtg_pris: int | None + vtg_beskrivelse: str | None + vtg_datoer: List[dict] | None + +class BulkVtgRequest(BaseModel): + approvals: List[VtgApproval] # --- FUNKSJONER --- def format_row(row): """ @@ -359,7 +372,8 @@ async def update_facility_full(facility_id: int, request: Request): 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer', 'guest_requirements', 'scrape_method', 'scrape_status_url', - 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at' + 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at', + 'greenfee_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke' ] update_data = {k: v for k, v in data.items() if k in allowed_fields} @@ -512,7 +526,113 @@ async def quick_edit_facility(facility_id: int, request: QuickEditRequest): await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2", request.value, facility_id) return {"status": "success"} - + +# --- GREENFEE "VASKERI" ENDEPUNKTER --- + +@app.get("/api/admin/greenfee/drafts") +async def get_greenfee_drafts(): + """Henter alle anlegg som har et ventende greenfee-forslag fra AI-skraperen.""" + async with app.state.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, slug, greenfee_url, greenfee, greenfee_draft + FROM facilities + WHERE greenfee_draft IS NOT NULL + AND greenfee_draft::text != '{}' + ORDER BY name ASC + """) + return [format_row(row) for row in rows] + +class BulkGreenfeeRequest(BaseModel): + approvals: List[GreenfeeApproval] + +@app.post("/api/admin/greenfee/approve-bulk") +async def approve_greenfee_bulk(request: BulkGreenfeeRequest): + """Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet.""" + async with app.state.pool.acquire() as conn: + async with conn.transaction(): + for approval in request.approvals: + await conn.execute(""" + UPDATE facilities + SET greenfee = $1::jsonb, + greenfee_updated_at = NOW(), + greenfee_draft = NULL + WHERE id = $2 + """, json.dumps(approval.greenfee), approval.facility_id) + return {"status": "success"} + +def run_greenfee_worker(facility_ids: List[int]): + """Kjører greenfee-skraperen i bakgrunnen.""" + print(f"🔄 STARTER GREENFEE-SKRAPING FOR IDER: {facility_ids}") + try: + import subprocess + ids_arg = ",".join(map(str, facility_ids)) + command = f"python -u scrape_greenfee.py --ids {ids_arg}" + subprocess.run(command, shell=True, check=True) + print(f"✅ GREENFEE-SKRAPING FULLFØRT FOR IDER: {facility_ids}") + except Exception as e: + print(f"🔥 FEIL UNDER GREENFEE-SKRAPING: {e}") + +@app.post("/api/admin/run-greenfee-scraper") +async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks): + """Tar imot IDer for greenfeeskraping og legger jobben i kø.""" + if not request.facility_ids: + raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") + background_tasks.add_task(run_greenfee_worker, request.facility_ids) + return {"status": "queued", "message": "Skraping startet"} + +# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER --- + +@app.get("/api/admin/vtg/drafts") +async def get_vtg_drafts(): + """Henter alle anlegg som har et ventende VTG-forslag.""" + async with app.state.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft + FROM facilities + WHERE vtg_draft IS NOT NULL + AND vtg_draft::text != '{}' + ORDER BY name ASC + """) + return [format_row(row) for row in rows] + +@app.post("/api/admin/vtg/approve-bulk") +async def approve_vtg_bulk(request: BulkVtgRequest): + """Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet.""" + async with app.state.pool.acquire() as conn: + async with conn.transaction(): + for approval in request.approvals: + datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]' + await conn.execute(""" + UPDATE facilities + SET vtg_pris = $1, + vtg_beskrivelse = $2, + vtg_datoer = $3::jsonb, + vtg_updated_at = NOW(), + vtg_draft = NULL + WHERE id = $4 + """, approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id) + return {"status": "success"} + +def run_vtg_worker(facility_ids: List[int]): + """Kjører VTG-skraperen i bakgrunnen.""" + print(f"🔄 STARTER VTG-SKRAPING FOR IDER: {facility_ids}") + try: + import subprocess + ids_arg = ",".join(map(str, facility_ids)) + command = f"python -u scrape_vtg.py --ids {ids_arg}" + subprocess.run(command, shell=True, check=True) + print(f"✅ VTG-SKRAPING FULLFØRT FOR IDER: {facility_ids}") + except Exception as e: + print(f"🔥 FEIL UNDER VTG-SKRAPING: {e}") + +@app.post("/api/admin/run-vtg-scraper") +async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks): + """Tar imot IDer for VTG-skraping og legger jobben i kø.""" + if not request.facility_ids: + raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") + background_tasks.add_task(run_vtg_worker, request.facility_ids) + return {"status": "queued", "message": "Skraping startet"} + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/backend/scrape_vtg.py b/backend/scrape_vtg.py new file mode 100644 index 0000000..797545d --- /dev/null +++ b/backend/scrape_vtg.py @@ -0,0 +1,161 @@ +""" +TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI +--------------------------------------------------------------------------- +Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider. +Støtter kommaseparerte URL-er. +--------------------------------------------------------------------------- +""" + +import asyncio +import asyncpg +import os +import json +import argparse +from bs4 import BeautifulSoup +from playwright.async_api import async_playwright +import google.generativeai as genai +from dotenv import load_dotenv + +load_dotenv() + +DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +if not GEMINI_API_KEY: + raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!") + +genai.configure(api_key=GEMINI_API_KEY) +model = genai.GenerativeModel('gemini-2.5-flash') + +async def fetch_page_text(url: str, browser) -> str: + url = url.strip() + if not url.startswith("http"): + return "" + + print(f" 🌐 Laster inn: {url}") + try: + page = await browser.new_page() + await page.goto(url, wait_until="domcontentloaded", timeout=15000) + html_content = await page.content() + await page.close() + + soup = BeautifulSoup(html_content, 'html.parser') + for script in soup(["script", "style", "nav", "footer", "header"]): + script.extract() + + return soup.get_text(separator=' ', strip=True) + except Exception as e: + print(f" ❌ Feil ved lasting av {url}: {e}") + return "" + +def analyze_vtg_with_gemini(text: str, club_name: str) -> dict: + print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...") + + prompt = f""" +Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fra nettsidene til "{club_name}" og koke dette ned til essensen om deres "Veien til Golf" (VTG) nybegynnerkurs. + +OPPGAVER: +1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet). +2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du MÅ inkludere informasjon om: + - Er lån av køller/utstyr inkludert i kurset? + - Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)? + - Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring"). + Ignorer uvesentlig støy og lange historiske utgreiinger. +3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste"). + +TEKST FRA NETTSIDEN: +{text} + +OPPGAVE: +Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur: +{{ + "foreslatt_vtg_pris": 1990, + "foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.", + "foreslatt_vtg_datoer": [ + {{"dato": "12.-14. mai", "status": "Fulltegnet"}}, + {{"dato": "5.-7. juni", "status": "Ledig"}} + ], + "ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr." +}} +Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom []. +""" + + try: + response = model.generate_content(prompt) + raw_response = response.text.strip() + + if raw_response.startswith("```json"): + raw_response = raw_response[7:] + if raw_response.endswith("```"): + raw_response = raw_response[:-3] + + return json.loads(raw_response.strip()) + except Exception as e: + print(f" ❌ AI-analyse feilet: {e}") + return None + +async def run_vtg_scraper(facility_ids=None): + print("🚀 Starter Veien til Golf (VTG) skraperen...") + conn = await asyncpg.connect(DB_URL) + + try: + query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''" + if facility_ids: + query += f" AND id IN ({','.join(map(str, facility_ids))})" + + facilities = await conn.fetch(query) + print(f"📋 Fant {len(facilities)} anlegg å skrape.") + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + + for facility in facilities: + fac_id = facility['id'] + name = facility['name'] + urls_raw = facility['vtg_lenke'] + + print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})") + + urls = [u.strip() for u in urls_raw.split(',')] + combined_text = "" + + for idx, url in enumerate(urls, 1): + page_text = await fetch_page_text(url, browser) + if page_text: + combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}" + + if len(combined_text) < 50: + print(" ⚠️ Fant for lite tekst, hopper over.") + continue + + draft_data = analyze_vtg_with_gemini(combined_text[:25000], name) + + if not draft_data: + continue + + print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.") + + await conn.execute(""" + UPDATE facilities + SET vtg_draft = $1::jsonb + WHERE id = $2 + """, json.dumps(draft_data), fac_id) + + print(" 💾 VTG-utkast lagret i databasen!") + + await browser.close() + + finally: + await conn.close() + print("\n🏁 Skraping fullført.") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Skrap VTG via AI.") + parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)") + args = parser.parse_args() + + ids_to_scrape = None + if args.ids: + ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")] + + asyncio.run(run_vtg_scraper(ids_to_scrape)) \ No newline at end of file diff --git a/frontend/src/app/admin/greenfee/page.tsx b/frontend/src/app/admin/greenfee/page.tsx new file mode 100644 index 0000000..ed8a782 --- /dev/null +++ b/frontend/src/app/admin/greenfee/page.tsx @@ -0,0 +1,203 @@ +"use client"; +import { useState, useEffect } from 'react'; +import { API_URL } from "@/config/constants"; +import Link from 'next/link'; + +export default function GreenfeeWasher() { + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState([]); + const [saving, setSaving] = useState(false); + + const fetchDrafts = () => { + setLoading(true); + fetch(`${API_URL}/admin/greenfee/drafts`) + .then(res => res.json()) + .then(data => { + const editableDrafts = data.map((f: any) => { + // JSONB fra Postgres kan noen ganger komme som en streng, + // vi må sikre at vi parser det hvis det trengs + let parsedDraft = f.greenfee_draft; + if (typeof parsedDraft === 'string') { + try { parsedDraft = JSON.parse(parsedDraft); } + catch (e) { console.error("Kunne ikke parse JSON", e); } + } + + // Hent ut selve listen (fallback til tom liste hvis noe er feil) + const greenfeeList = parsedDraft?.foreslatt_greenfee || []; + + return { + ...f, + greenfee_draft: parsedDraft, // Lagre den parsede versjonen for visning + edit_greenfee: greenfeeList // Dette er arrayet som binder seg til input-feltene + }; + }); + setDrafts(editableDrafts); + setLoading(false); + }) + .catch(() => setLoading(false)); + }; + + useEffect(() => { fetchDrafts(); }, []); + + const toggleSelectAll = (checked: boolean) => { + if (checked) setSelectedIds(drafts.map(d => d.id)); + else setSelectedIds([]); + }; + + const toggleOne = (id: number) => { + if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id)); + else setSelectedIds([...selectedIds, id]); + }; + + const removeRow = (facilityId: number, rowIndex: number) => { + setDrafts(drafts.map(d => { + if (d.id === facilityId) { + const newRows = [...d.edit_greenfee]; + newRows.splice(rowIndex, 1); + return { ...d, edit_greenfee: newRows }; + } + return d; + })); + }; + + const updateField = (facilityId: number, rowIndex: number, field: string, value: string | number) => { + setDrafts(drafts.map(d => { + if (d.id === facilityId) { + const newRows = [...d.edit_greenfee]; + newRows[rowIndex] = { ...newRows[rowIndex], [field]: value }; + return { ...d, edit_greenfee: newRows }; + } + return d; + })); + }; + + const handleApprove = async () => { + const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({ + facility_id: d.id, + greenfee: d.edit_greenfee.map((row: any) => ({ + banenavn: row.banenavn || "", + priskategori: row.priskategori || "", + pris_voksne: Number(row.pris_voksne) || null, + pris_junior: Number(row.pris_junior) || null + })) + })); + + if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne."); + + setSaving(true); + try { + const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approvals: toApprove }) + }); + if (res.ok) { + alert(`${toApprove.length} anlegg oppdatert!`); + setSelectedIds([]); + fetchDrafts(); + } else { + alert("Noe gikk galt under lagring."); + } + } catch (e) { + alert("Nettverksfeil"); + } + setSaving(false); + }; + + if (loading) return
Laster utkast...
; + + return ( +
+
+
+
+ ← Tilbake til oversikten +

Greenfee-Vaskeriet

+

Sjekk at prisene gir mening før publisering.

+
+ +
+ + {drafts.length === 0 ? ( +
+ 🧹 +

Alt er rent og pent!

+
+ ) : ( +
+
+ 0} onChange={(e) => toggleSelectAll(e.target.checked)} /> + Velg Alle +
+ + {drafts.map(draft => ( +
+
+
toggleOne(draft.id)} />
+
+
+

{draft.name} ID: {draft.id}

+ Sjekk Nettside ↗ +
+ + {draft.greenfee_draft?.ai_begrunnelse && ( +
+ 🤖 AI Begrunnelse: {draft.greenfee_draft.ai_begrunnelse} +
+ )} + + {draft.greenfee_draft?.foreslatt_avtaleklubber?.length > 0 && ( +
+ 🤝 AI fant disse avtaleklubbene i teksten: {draft.greenfee_draft.foreslatt_avtaleklubber.join(', ')} +
+ )} + +
+
+

Slik ser det ut i databasen nå:

+
+ {draft.greenfee && draft.greenfee.length > 0 ? draft.greenfee.map((g: any, i: number) => ( +
+ {g.banenavn} - {g.priskategori} + V: {g.pris_voksne || '-'} | J: {g.pris_junior || '-'} +
+ )) : "Ingen priser registrert."} +
+
+ +
+

Nytt forslag å godkjenne:

+
+ {draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => ( +
+ updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" /> + updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" /> + updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" /> + updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" /> + +
+ ))} + +
+
+
+
+
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 808f333..3a69fb6 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,10 +1,6 @@ "use client"; /** - * TEE OFF ADMIN DASHBOARD v2.1 - MISSION CONTROL + INLINE EDIT - * --------------------------------------------------------------------------- - * FUNKSJON: "Mission Control" med faner for Banestatus og Medlemskap, - * og klikk-for-å-redigere URL-er direkte i tabellen! - * --------------------------------------------------------------------------- + * TEE OFF ADMIN DASHBOARD v3.0 - THE GRAND SLAM (Alle 4 skrapere) */ import { useState, useEffect, useMemo } from 'react'; @@ -12,7 +8,6 @@ import { API_URL } from "@/config/constants"; import ScrapeMethodSelect from "@/components/ScrapeMethodSelect"; import Link from 'next/link'; -// KOMPONENT FOR HURTIGREDIGERING AV URL-ER const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => { const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(initialValue || ''); @@ -27,15 +22,7 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n if (isEditing) { return (
-