663 lines
No EOL
27 KiB
Text
663 lines
No EOL
27 KiB
Text
"""
|
|
TEE OFF BACKEND API v3.8.0 - KOBLET PÅ FULL ADMIN REDIGERING
|
|
---------------------------------------------------------------------------
|
|
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, Any
|
|
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 CourseStatusUpdate(BaseModel):
|
|
id: int
|
|
status: str
|
|
|
|
class ScrapeSettingsUpdate(BaseModel):
|
|
scrape_method: Optional[str] = None
|
|
scrape_status_url: Optional[str] = None
|
|
scrape_status_selector: Optional[str] = None
|
|
ai_instruction: Optional[str] = None
|
|
courses: Optional[List[CourseStatusUpdate]] = []
|
|
|
|
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
|
|
class ScrapeRunRequest(BaseModel):
|
|
facility_ids: List[int]
|
|
|
|
class MembershipDraftApproval(BaseModel):
|
|
facility_id: int
|
|
navn_standard_medlemskap: Optional[str] = None
|
|
standard_medlemskap: Optional[int] = None
|
|
standard_medlemskap_kommentarer: Optional[str] = None
|
|
navn_rimeligste_alternativ: Optional[str] = None
|
|
rimeligste_alternativ: Optional[int] = None
|
|
|
|
class BulkApprovalRequest(BaseModel):
|
|
approvals: List[MembershipDraftApproval]
|
|
|
|
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):
|
|
"""
|
|
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', 'slope_valid_until', 'membership_updated_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', 'golfpakker', 'cooperating_clubs', 'vtg_datoer'
|
|
]
|
|
json_dict_fields = [
|
|
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
|
|
]
|
|
|
|
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}")
|
|
|
|
try:
|
|
ids_arg = ",".join(map(str, facility_ids))
|
|
|
|
# NYTT: Bruker "python -u" for LIVE logging, og fjerner "> /dev/null 2>&1"
|
|
command = f"python -u scrape_status.py --ids {ids_arg}"
|
|
|
|
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}")
|
|
|
|
def run_membership_worker(facility_ids: List[int]):
|
|
"""Kjører medlemskap-skraperen i bakgrunnen."""
|
|
print(f"🔄 STARTER MEDLEMSKAP-SKRAPING FOR IDER: {facility_ids}")
|
|
try:
|
|
ids_arg = ",".join(map(str, facility_ids))
|
|
command = f"python -u scrape_membership.py --ids {ids_arg}"
|
|
subprocess.run(command, shell=True, check=True)
|
|
print(f"✅ MEDLEMSKAP-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
|
except Exception as e:
|
|
print(f"🔥 FEIL UNDER MEDLEMSKAP-SKRAPING: {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.8.0", 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 id, 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 inkludert AI instruks
|
|
await conn.execute("""
|
|
UPDATE facilities
|
|
SET scrape_method = $1,
|
|
scrape_status_url = $2,
|
|
scrape_status_selector = $3,
|
|
ai_instruction = $4
|
|
WHERE id = $5
|
|
""",
|
|
settings.scrape_method,
|
|
settings.scrape_status_url,
|
|
settings.scrape_status_selector,
|
|
settings.ai_instruction,
|
|
facility_id)
|
|
|
|
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
|
|
if settings.scrape_method == 'manual' and settings.courses:
|
|
for c in settings.courses:
|
|
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.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 FOR FULL OPPDATERING (JSON-EDITOR) ---
|
|
@app.put("/api/admin/facilities/{facility_id}/full")
|
|
async def update_facility_full(facility_id: int, request: Request):
|
|
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
|
|
data = await request.json()
|
|
|
|
# Felter som er trygge å oppdatere manuelt på anlegget
|
|
allowed_fields = [
|
|
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
|
|
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
|
|
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
|
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
|
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
|
|
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_data',
|
|
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
|
'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',
|
|
'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}
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
async with conn.transaction(): # Sikrer at alt lagres samlet
|
|
|
|
# 1. OPPDATER ANLEGG (FACILITIES)
|
|
if update_data:
|
|
set_clauses = []
|
|
values = []
|
|
|
|
# Definer hvilke felt som er datoer i databasen
|
|
date_fields = ['membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'status_updated_at']
|
|
|
|
for i, (k, v) in enumerate(update_data.items(), 1):
|
|
if isinstance(v, (dict, list)):
|
|
set_clauses.append(f"{k} = ${i}::jsonb")
|
|
values.append(json.dumps(v))
|
|
elif k in date_fields:
|
|
set_clauses.append(f"{k} = ${i}")
|
|
# Håndter tomme datoer og konverter til Python datetime
|
|
if v == "" or v is None:
|
|
values.append(None)
|
|
else:
|
|
# Tving strengen over til et ekte datetime-objekt.
|
|
# .replace() håndterer Next.js' "Z"-format.
|
|
dt_str = str(v).replace("Z", "+00:00")
|
|
try:
|
|
dt_obj = datetime.fromisoformat(dt_str)
|
|
values.append(dt_obj)
|
|
except ValueError:
|
|
values.append(None)
|
|
else:
|
|
set_clauses.append(f"{k} = ${i}")
|
|
values.append(v)
|
|
|
|
values.append(facility_id)
|
|
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
|
|
await conn.execute(query, *values)
|
|
|
|
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
|
|
courses = data.get('courses', [])
|
|
for course in courses:
|
|
course_id = course.get('id')
|
|
if course_id:
|
|
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
|
|
valid_until_str = course.get('slope_valid_until')
|
|
if valid_until_str == "" or valid_until_str is None:
|
|
valid_until = None
|
|
else:
|
|
# Gjør om strengen til et ekte date-objekt for asyncpg
|
|
try:
|
|
date_part = valid_until_str.split('T')[0]
|
|
valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
valid_until = None
|
|
|
|
await conn.execute("""
|
|
UPDATE courses
|
|
SET name=$1, par=$2, length_meters=$3, architect=$4,
|
|
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
|
|
slope_valid_until=$8
|
|
WHERE id=$9 AND facility_id=$10
|
|
""",
|
|
course.get('name'), course.get('par'), course.get('length_meters'),
|
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
|
json.dumps(course.get('tee_boxes', {})), valid_until, course_id, facility_id)
|
|
|
|
# 3. OPPDATER HULL PÅ BANEN (HOLES)
|
|
holes = course.get('holes', [])
|
|
for hole in holes:
|
|
hole_id = hole.get('id')
|
|
if hole_id:
|
|
await conn.execute("""
|
|
UPDATE holes
|
|
SET par=$1, hcp_index=$2, lengths=$3::jsonb
|
|
WHERE id=$4 AND course_id=$5
|
|
""",
|
|
hole.get('par'), hole.get('hcp_index'),
|
|
json.dumps(hole.get('lengths', {})), hole_id, course_id)
|
|
|
|
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
|
|
|
|
# --- 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}")
|
|
|
|
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.post("/api/admin/run-membership-scraper")
|
|
async def run_membership_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
|
"""Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
|
|
if not request.facility_ids:
|
|
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
|
|
|
print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
|
|
background_tasks.add_task(run_membership_worker, request.facility_ids)
|
|
|
|
return {"status": "queued", "message": f"Medlemskapsskraping 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)}
|
|
|
|
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---
|
|
|
|
@app.get("/api/admin/membership/drafts")
|
|
async def get_membership_drafts():
|
|
"""Henter alle anlegg som har et ventende forslag fra AI-skraperen."""
|
|
async with app.state.pool.acquire() as conn:
|
|
rows = await conn.fetch("""
|
|
SELECT id, name, slug, medlemskap_url,
|
|
navn_standard_medlemskap, standard_medlemskap,
|
|
navn_rimeligste_alternativ, rimeligste_alternativ,
|
|
membership_draft
|
|
FROM facilities
|
|
WHERE membership_draft IS NOT NULL
|
|
AND membership_draft::text != '{}'
|
|
ORDER BY name ASC
|
|
""")
|
|
return [format_row(row) for row in rows]
|
|
|
|
@app.post("/api/admin/membership/approve-bulk")
|
|
async def approve_membership_bulk(request: BulkApprovalRequest):
|
|
"""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 navn_standard_medlemskap = $1,
|
|
standard_medlemskap = $2,
|
|
standard_medlemskap_kommentarer = $3,
|
|
navn_rimeligste_alternativ = $4,
|
|
rimeligste_alternativ = $5,
|
|
membership_updated_at = NOW(),
|
|
membership_draft = NULL
|
|
WHERE id = $6
|
|
""",
|
|
approval.navn_standard_medlemskap,
|
|
approval.standard_medlemskap,
|
|
approval.standard_medlemskap_kommentarer,
|
|
approval.navn_rimeligste_alternativ,
|
|
approval.rimeligste_alternativ,
|
|
approval.facility_id)
|
|
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
|
|
|
|
@app.patch("/api/admin/facilities/{facility_id}/quick-edit")
|
|
async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
|
|
"""Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
|
|
# Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering
|
|
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector']
|
|
if request.field not in allowed_fields:
|
|
raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
|
|
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) |