Nye-TeeOff/backend/main.py

1131 lines
45 KiB
Python
Raw Normal View History

"""
2026-04-10 09:52:34 +02:00
TEE OFF BACKEND API v3.8.0 - KOBLET 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.
2026-03-02 09:56:37 +01:00
LOV: Aldri trunker eller slett logikk for "effektivitet".
---------------------------------------------------------------------------
"""
2026-04-10 18:37:33 +02:00
from fastapi import FastAPI, HTTPException, Response, Request, Query
2026-02-26 09:20:51 +01:00
from fastapi.middleware.cors import CORSMiddleware
2026-04-11 09:54:54 +02:00
from fastapi.responses import JSONResponse
2026-02-26 09:20:51 +01:00
from contextlib import asynccontextmanager
import asyncpg
import json
import pyotp
import os
2026-04-13 15:29:43 +02:00
import re
from datetime import datetime, date, timedelta
2026-04-13 15:29:43 +02:00
from pathlib import Path
from jose import jwt, JWTError
from passlib.context import CryptContext
from dotenv import load_dotenv
2026-04-11 09:54:54 +02:00
import qrcode
import qrcode.image.svg
2026-03-05 05:18:03 +01:00
from pydantic import BaseModel
from typing import Optional, List, Any
2026-04-10 18:37:33 +02:00
from scrape_jobs import (
SCRAPE_JOB_TYPES,
enqueue_scrape_job,
ensure_scrape_jobs_table,
list_scrape_jobs,
)
2026-03-05 05:18:03 +01:00
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"
2026-03-02 09:56:37 +01:00
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
2026-02-26 09:20:51 +01:00
2026-04-11 09:54:54 +02:00
async def validate_admin_session_token(token: str) -> str:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if not username:
raise JWTError()
return username
except JWTError as exc:
raise HTTPException(status_code=401, detail="Ugyldig eller utløpt admin-sesjon") from exc
2026-03-05 05:18:03 +01:00
# --- PYDANTIC MODELLER ---
2026-03-05 09:25:15 +01:00
class CourseStatusUpdate(BaseModel):
id: int
status: str
2026-03-05 05:18:03 +01:00
class ScrapeSettingsUpdate(BaseModel):
scrape_method: Optional[str] = None
scrape_status_url: Optional[str] = None
scrape_status_selector: Optional[str] = None
2026-03-05 09:25:15 +01:00
ai_instruction: Optional[str] = None
courses: Optional[List[CourseStatusUpdate]] = []
2026-03-05 05:18:03 +01:00
# 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]
2026-03-12 13:39:10 +01:00
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]
2026-04-11 09:54:54 +02:00
class AdminPasswordConfirm(BaseModel):
password: str
2026-04-13 15:29:43 +02:00
class ArticleUpsertRequest(BaseModel):
slug: str
title: str
description: Optional[str] = None
excerpt: Optional[str] = None
eyebrow: Optional[str] = None
location_label: Optional[str] = None
facility_name: Optional[str] = None
facility_slug: Optional[str] = None
author_name: Optional[str] = None
status: Optional[str] = "draft"
hero_images: Optional[List[dict[str, Any]]] = []
content_html: Optional[str] = None
source_url: Optional[str] = None
source_label: Optional[str] = None
published_at: Optional[str] = None
updated_at: Optional[str] = None
2026-03-05 05:18:03 +01:00
# --- FUNKSJONER ---
2026-02-27 09:35:30 +01:00
def format_row(row):
"""
Vasker data fra databasen:
1. Konverterer datoer til ISO-format.
2026-03-02 09:56:37 +01:00
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.
2026-02-27 09:35:30 +01:00
"""
if row is None:
return None
d = dict(row)
2026-04-10 18:37:33 +02:00
for key in [
'status_updated_at', 'created_at', 'slope_valid_until',
'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at'
2026-04-10 18:37:33 +02:00
]:
2026-02-27 09:35:30 +01:00
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
2026-02-27 09:35:30 +01:00
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer'
2026-02-27 09:35:30 +01:00
]
2026-03-02 09:56:37 +01:00
json_dict_fields = [
2026-04-10 18:37:33 +02:00
'amenities', 'vtg', 'nsg_data', 'golfamore_data',
'membership_draft', 'greenfee_draft', 'vtg_draft'
2026-03-02 09:56:37 +01:00
]
2026-02-27 09:35:30 +01:00
for field in json_list_fields:
if field in d:
val = d[field]
2026-03-02 09:56:37 +01:00
if val is None:
d[field] = []
2026-02-27 09:35:30 +01:00
elif isinstance(val, str):
2026-03-02 09:56:37 +01:00
try:
d[field] = json.loads(val)
except:
d[field] = []
elif not isinstance(val, list):
d[field] = []
2026-02-27 09:35:30 +01:00
for field in json_dict_fields:
if field in d:
val = d[field]
2026-03-02 09:56:37 +01:00
if val is None:
d[field] = {}
2026-02-27 09:35:30 +01:00
elif isinstance(val, str):
2026-03-02 09:56:37 +01:00
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-04-11 09:54:54 +02:00
def generate_totp_qr_svg(provisioning_uri: str) -> str:
image = qrcode.make(
provisioning_uri,
image_factory=qrcode.image.svg.SvgPathImage,
box_size=8,
border=3,
)
return image.to_string(encoding="unicode")
2026-04-13 15:29:43 +02:00
def format_article_row(row):
if row is None:
return None
data = dict(row)
for key in ["published_at", "updated_at", "created_at"]:
if isinstance(data.get(key), (date, datetime)):
data[key] = data[key].isoformat()
hero_images = data.get("hero_images")
if hero_images is None:
data["hero_images"] = []
elif isinstance(hero_images, str):
try:
data["hero_images"] = json.loads(hero_images)
except Exception:
data["hero_images"] = []
elif not isinstance(hero_images, list):
data["hero_images"] = []
return data
def normalize_article_status(status: str | None) -> str:
normalized = str(status or "draft").strip().lower()
if normalized not in {"draft", "published"}:
raise HTTPException(status_code=400, detail="Ugyldig artikkelstatus. Bruk 'draft' eller 'published'.")
return normalized
def parse_optional_datetime(value: str | None) -> datetime | None:
if value is None:
return None
trimmed = str(value).strip()
if not trimmed:
return None
try:
return datetime.fromisoformat(trimmed.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Ugyldig datoformat: {value}") from exc
def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
sanitized: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
continue
src = str(item.get("src") or "").strip()
if not src:
continue
sanitized.append(
{
"src": src,
"alt": str(item.get("alt") or "").strip(),
"caption": str(item.get("caption") or "").strip(),
}
)
return sanitized
def humanize_slug(slug: str | None) -> str:
if not slug:
return "Ukjent bane"
return " ".join(part.capitalize() for part in str(slug).split("-") if part)
ARTICLE_IMAGE_PATTERN = re.compile(r"<img\b[^>]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE)
def extract_html_image_urls(html: str | None) -> list[str]:
urls: list[str] = []
for url in ARTICLE_IMAGE_PATTERN.findall(html or ""):
if not isinstance(url, str) or not url.strip():
continue
urls.append(url.strip())
deduped: dict[str, None] = {}
for url in urls:
deduped[url] = None
return list(deduped.keys())
2026-04-11 09:54:54 +02:00
async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: str | None = None):
2026-04-10 18:37:33 +02:00
if job_type not in SCRAPE_JOB_TYPES:
raise HTTPException(status_code=400, detail=f"Ugyldig jobbtype: {job_type}")
if not facility_ids:
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
2026-03-05 05:18:03 +01:00
2026-04-11 16:01:36 +02:00
requested_ids = sorted({int(facility_id) for facility_id in facility_ids if str(facility_id).strip()})
job, status = await enqueue_scrape_job(app.state.pool, job_type, requested_ids, requested_by=requested_by)
was_created = status == "queued"
overlapping_ids = sorted({int(facility_id) for facility_id in (job.get("overlapping_facility_ids") or [])})
available_ids = [facility_id for facility_id in requested_ids if facility_id not in overlapping_ids]
2026-04-10 18:37:33 +02:00
message = (
f"{job_type.capitalize()}-skraping for {len(job['facility_ids'])} anlegg ble lagt i kø."
if was_created
else f"Fant allerede en aktiv {job_type}-jobb for samme anlegg."
)
2026-04-11 16:01:36 +02:00
if status == "conflict":
message = (
f"Kunne ikke legge jobben i kø fordi {len(overlapping_ids)} valgt"
f" anlegg allerede inngår i en aktiv {job_type}-jobb."
)
2026-04-10 18:37:33 +02:00
return {
"status": status,
"message": message,
"job": job,
2026-04-11 16:01:36 +02:00
"conflicting_facility_ids": overlapping_ids,
"idle_facility_ids": available_ids,
2026-04-10 18:37:33 +02:00
}
2026-03-12 13:39:10 +01:00
2026-03-05 05:18:03 +01:00
async def ensure_facility_columns(conn):
"""Legger til nye facility-kolonner ved behov."""
await conn.execute("""
ALTER TABLE facilities
ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ
""")
2026-04-13 15:29:43 +02:00
async def ensure_articles_table(conn):
await conn.execute("""
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
excerpt TEXT,
eyebrow VARCHAR(120) DEFAULT 'Banebesøk',
location_label VARCHAR(255),
facility_name VARCHAR(255),
facility_slug VARCHAR(255),
author_name VARCHAR(255),
status VARCHAR(32) NOT NULL DEFAULT 'draft',
hero_images JSONB NOT NULL DEFAULT '[]'::jsonb,
content_html TEXT,
source_url TEXT,
source_label VARCHAR(255),
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
await conn.execute("CREATE INDEX IF NOT EXISTS articles_status_idx ON articles (status)")
await conn.execute("CREATE INDEX IF NOT EXISTS articles_published_at_idx ON articles (published_at DESC)")
2026-02-26 09:20:51 +01:00
@asynccontextmanager
async def lifespan(app: FastAPI):
2026-03-02 09:56:37 +01:00
# Opprett database-pool ved start
2026-02-27 08:53:14 +01:00
try:
2026-03-02 09:56:37 +01:00
print(f"📡 Forsøker å koble til database på: {DB_URL}")
2026-02-27 09:35:30 +01:00
app.state.pool = await asyncpg.create_pool(
2026-03-02 09:56:37 +01:00
DB_URL,
min_size=5,
max_size=20,
command_timeout=60
2026-02-27 09:35:30 +01:00
)
2026-04-10 18:37:33 +02:00
async with app.state.pool.acquire() as conn:
await ensure_facility_columns(conn)
2026-04-13 15:29:43 +02:00
await ensure_articles_table(conn)
2026-04-10 18:37:33 +02:00
await ensure_scrape_jobs_table(conn)
2026-03-02 09:56:37 +01:00
print("✅ Database tilkoblet og pool opprettet")
2026-02-27 08:53:14 +01:00
except Exception as e:
2026-03-02 09:56:37 +01:00
print(f"❌ Databasefeil under oppstart: {e}")
2026-02-27 08:53:14 +01:00
raise e
2026-02-26 09:20:51 +01:00
yield
2026-03-02 09:56:37 +01:00
# Lukk pool ved avslutning
2026-02-26 09:20:51 +01:00
await app.state.pool.close()
2026-04-10 09:52:34 +02:00
app = FastAPI(title="TeeOff API v3.8.0", 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
2026-04-11 09:54:54 +02:00
@app.middleware("http")
async def require_admin_session_for_admin_routes(request: Request, call_next):
if request.url.path.startswith("/api/admin"):
token = request.cookies.get("admin_session")
if not token:
return JSONResponse(status_code=401, content={"detail": "Admin-innlogging kreves"})
try:
username = await validate_admin_session_token(token)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
request.state.admin_username = username
return await call_next(request)
# --- AUTH ENDPOINTS ---
@app.post("/api/auth/login")
async def login(data: dict):
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
2026-03-02 19:39:40 +01:00
print(f"🔐 Loggin-forsøk for: {data.get('username')}")
2026-03-02 09:56:37 +01:00
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow(
"SELECT * FROM admins WHERE username = $1 OR email = $1",
data.get('username')
)
2026-03-02 09:56:37 +01:00
if not admin:
print(" - ❌ Bruker ikke funnet i databasen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
2026-03-02 09:56:37 +01:00
h = admin['password_hash']
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
try:
2026-03-02 19:39:40 +01:00
is_valid = pwd_context.verify(data.get('password'), h)
2026-03-02 09:56:37 +01:00
except Exception as e:
2026-03-02 19:39:40 +01:00
print(f" - 🔥 FEIL VED LESING AV HASH: {e}")
2026-03-02 09:56:37 +01:00
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
2026-03-02 19:39:40 +01:00
if not is_valid:
print(" - ❌ Passordet samsvarer ikke med hashen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
2026-03-02 09:56:37 +01:00
temp_token = jwt.encode(
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
SECRET_KEY, algorithm=ALGORITHM
)
2026-03-02 09:56:37 +01:00
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):
2026-03-02 09:56:37 +01:00
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
try:
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
2026-03-02 09:56:37 +01:00
if not payload.get("partial"):
raise JWTError()
username = payload.get("sub")
2026-03-02 09:56:37 +01:00
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)
2026-03-02 09:56:37 +01:00
totp = pyotp.TOTP(admin['otp_secret'])
if not totp.verify(data.get('code')):
2026-03-02 09:56:37 +01:00
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
)
2026-03-02 09:56:37 +01:00
# Sett som HTTP-only cookie
response.set_cookie(
2026-03-02 09:56:37 +01:00
key="admin_session",
value=final_token,
httponly=True,
samesite="lax",
secure=False # Sett til True i produksjon (HTTPS)
)
return {"status": "success"}
2026-04-11 09:54:54 +02:00
@app.post("/api/auth/logout")
async def logout(response: Response):
"""Logger ut admin ved å slette sesjonscookien."""
response.delete_cookie(
key="admin_session",
httponly=True,
samesite="lax",
secure=False,
)
return {"status": "success"}
# --- DATA ENDPOINTS ---
2026-02-26 09:20:51 +01:00
@app.get("/api/facilities")
async def get_facilities():
2026-03-02 09:56:37 +01:00
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
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 (
2026-03-05 09:25:15 +01:00
SELECT id, 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):
2026-03-02 09:56:37 +01:00
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
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:
2026-03-02 09:56:37 +01:00
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
2026-02-27 09:35:30 +01:00
return format_row(row)
2026-04-13 15:29:43 +02:00
@app.get("/api/course-visits")
async def get_course_visits():
"""Henter publiserte Banebesøk-artikler."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT *
FROM articles
WHERE status = 'published'
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
""")
return [format_article_row(row) for row in rows]
@app.get("/api/course-visits/{slug}")
async def get_course_visit(slug: str):
"""Henter én publisert Banebesøk-artikkel."""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT *
FROM articles
WHERE slug = $1 AND status = 'published'
""", slug)
if not row:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return format_article_row(row)
2026-03-05 05:18:03 +01:00
# --- ADMIN ENDPOINTS ---
2026-04-13 15:29:43 +02:00
@app.get("/api/admin/articles")
async def get_admin_articles(status: Optional[str] = Query(default="all")):
"""Henter artikler for admin med valgfritt statusfilter."""
normalized_status = str(status or "all").strip().lower()
if normalized_status not in {"all", "draft", "published"}:
raise HTTPException(status_code=400, detail="Ugyldig statusfilter")
query = """
SELECT *
FROM articles
{where_clause}
ORDER BY COALESCE(published_at, created_at) DESC, updated_at DESC, id DESC
"""
async with app.state.pool.acquire() as conn:
if normalized_status == "all":
rows = await conn.fetch(query.format(where_clause=""))
else:
rows = await conn.fetch(
query.format(where_clause="WHERE status = $1"),
normalized_status,
)
return [format_article_row(row) for row in rows]
@app.get("/api/admin/articles/{article_id}")
async def get_admin_article(article_id: int):
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM articles WHERE id = $1", article_id)
if not row:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return format_article_row(row)
@app.post("/api/admin/articles")
async def upsert_admin_article(request: ArticleUpsertRequest):
status = normalize_article_status(request.status)
published_at = parse_optional_datetime(request.published_at)
updated_at = parse_optional_datetime(request.updated_at) or datetime.utcnow()
if status == "published" and not published_at:
published_at = datetime.utcnow()
hero_images = sanitize_hero_images(request.hero_images)
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO articles (
slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images,
content_html, source_url, source_label, published_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11::jsonb,
$12, $13, $14, $15, $16
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
excerpt = EXCLUDED.excerpt,
eyebrow = EXCLUDED.eyebrow,
location_label = EXCLUDED.location_label,
facility_name = EXCLUDED.facility_name,
facility_slug = EXCLUDED.facility_slug,
author_name = EXCLUDED.author_name,
status = EXCLUDED.status,
hero_images = EXCLUDED.hero_images,
content_html = EXCLUDED.content_html,
source_url = EXCLUDED.source_url,
source_label = EXCLUDED.source_label,
published_at = EXCLUDED.published_at,
updated_at = EXCLUDED.updated_at
RETURNING *
""",
request.slug.strip(),
request.title.strip(),
(request.description or "").strip() or None,
(request.excerpt or "").strip() or None,
(request.eyebrow or "Banebesøk").strip(),
(request.location_label or "").strip() or None,
(request.facility_name or "").strip() or None,
(request.facility_slug or "").strip() or None,
(request.author_name or "TeeOff").strip(),
status,
json.dumps(hero_images),
request.content_html or "",
(request.source_url or "").strip() or None,
(request.source_label or "").strip() or None,
published_at,
updated_at,
)
return format_article_row(row)
@app.delete("/api/admin/articles/{article_id}")
async def delete_admin_article(article_id: int):
async with app.state.pool.acquire() as conn:
deleted = await conn.fetchrow("DELETE FROM articles WHERE id = $1 RETURNING id", article_id)
if not deleted:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return {"status": "success"}
@app.post("/api/admin/articles/seed-imported")
async def seed_admin_articles_from_imported_json():
imported_path = Path("/opt/teeoff/frontend/src/content/importedMeninger.json")
if not imported_path.exists():
raise HTTPException(status_code=404, detail="Fant ikke importedMeninger.json")
try:
imported_articles = json.loads(imported_path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=500, detail="Kunne ikke lese importedMeninger.json") from exc
async with app.state.pool.acquire() as conn:
facility_rows = await conn.fetch("SELECT slug, name, county FROM facilities")
facility_lookup = {
str(row["slug"]): {
"name": row["name"],
"county": row["county"],
}
for row in facility_rows
}
upserted_count = 0
async with conn.transaction():
for item in imported_articles:
facility_slug = item.get("primaryFacilitySlug") or ((item.get("facilitySlugs") or [None])[0])
if not facility_slug:
continue
facility = facility_lookup.get(str(facility_slug), {})
content_html = str(item.get("contentHtml") or "")
featured_image = item.get("featuredImage") or {}
hero_images: list[dict[str, str]] = []
featured_url = str(featured_image.get("url") or "").strip()
if featured_url:
hero_images.append(
{
"src": featured_url,
"alt": str(featured_image.get("alt") or item.get("title") or "").strip(),
"caption": str(featured_image.get("caption") or item.get("title") or "").strip(),
}
)
for url in extract_html_image_urls(content_html)[:5]:
if any(existing["src"] == url for existing in hero_images):
continue
hero_images.append(
{
"src": url,
"alt": str(item.get("title") or "").strip(),
"caption": str(item.get("title") or "").strip(),
}
)
published_at = parse_optional_datetime(item.get("publishedAt"))
updated_at = parse_optional_datetime(item.get("updatedAt")) or published_at or datetime.utcnow()
await conn.execute("""
INSERT INTO articles (
slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images,
content_html, source_url, source_label, published_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, 'published', $10::jsonb,
$11, $12, $13, $14, $15
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
excerpt = EXCLUDED.excerpt,
eyebrow = EXCLUDED.eyebrow,
location_label = EXCLUDED.location_label,
facility_name = EXCLUDED.facility_name,
facility_slug = EXCLUDED.facility_slug,
author_name = EXCLUDED.author_name,
status = EXCLUDED.status,
hero_images = EXCLUDED.hero_images,
content_html = EXCLUDED.content_html,
source_url = EXCLUDED.source_url,
source_label = EXCLUDED.source_label,
published_at = EXCLUDED.published_at,
updated_at = EXCLUDED.updated_at
""",
str(item.get("slug") or "").strip(),
str(item.get("title") or "").strip(),
str(item.get("excerpt") or "").strip() or None,
str(item.get("excerpt") or "").strip() or None,
"Banebesøk",
str(facility.get("county") or "Norge"),
str(facility.get("name") or humanize_slug(str(facility_slug))),
str(facility_slug),
str(((item.get("author") or {}).get("name")) or "TeeOff"),
json.dumps(hero_images),
content_html,
str(item.get("link") or "").strip() or None,
"Importert fra gamle TeeOff",
published_at,
updated_at,
)
upserted_count += 1
return {"status": "success", "count": upserted_count}
2026-03-05 05:18:03 +01:00
@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.")
2026-03-05 09:25:15 +01:00
# Oppdater verdiene i databasen inkludert AI instruks
2026-03-05 05:18:03 +01:00
await conn.execute("""
UPDATE facilities
SET scrape_method = $1,
scrape_status_url = $2,
2026-03-05 09:25:15 +01:00
scrape_status_selector = $3,
ai_instruction = $4
WHERE id = $5
2026-03-05 05:18:03 +01:00
""",
settings.scrape_method,
settings.scrape_status_url,
settings.scrape_status_selector,
2026-03-05 09:25:15 +01:00
settings.ai_instruction,
2026-03-05 05:18:03 +01:00
facility_id)
2026-03-05 09:25:15 +01:00
# 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)
2026-03-05 05:18:03 +01:00
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',
'footnote_updated_at'
]
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:
if 'footnote' in update_data and 'footnote_updated_at' not in update_data:
existing_footnote = await conn.fetchval(
"SELECT footnote FROM facilities WHERE id = $1",
facility_id
)
incoming_footnote = str(update_data.get('footnote') or '').strip()
current_footnote = str(existing_footnote or '').strip()
if incoming_footnote != current_footnote:
update_data['footnote_updated_at'] = datetime.utcnow() if incoming_footnote else None
set_clauses = []
values = []
2026-04-10 09:52:34 +02:00
# Definer hvilke felt som er datoer i databasen
date_fields = [
'membership_updated_at',
'greenfee_updated_at',
'vtg_updated_at',
'status_updated_at',
'footnote_updated_at'
]
2026-04-10 09:52:34 +02:00
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))
2026-04-10 09:52:34 +02:00
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)
2026-04-10 09:52:34 +02:00
valid_until_str = course.get('slope_valid_until')
if valid_until_str == "" or valid_until_str is None:
valid_until = None
2026-04-10 09:52:34 +02:00
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."}
2026-03-05 05:18:03 +01:00
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
2026-04-10 18:37:33 +02:00
@app.get("/api/admin/scrape-jobs")
async def get_scrape_jobs(job_type: Optional[str] = Query(default=None), limit: int = Query(default=10, ge=1, le=50)):
"""Henter siste scrape-jobber, evt. filtrert på type."""
if job_type and job_type not in SCRAPE_JOB_TYPES:
raise HTTPException(status_code=400, detail="Ugyldig jobbtype.")
return await list_scrape_jobs(app.state.pool, job_type=job_type, limit=limit)
2026-04-11 09:54:54 +02:00
@app.post("/api/admin/2fa/setup")
async def get_admin_2fa_setup(request: AdminPasswordConfirm, http_request: Request):
"""Verifiserer passord på nytt og returnerer TOTP-oppsett for 1Password/Authenticator."""
username = getattr(http_request.state, "admin_username", None)
if not username:
raise HTTPException(status_code=401, detail="Admin-innlogging kreves")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow(
"SELECT username, email, password_hash, otp_secret FROM admins WHERE username = $1",
username,
)
if not admin:
raise HTTPException(status_code=404, detail="Admin-bruker ble ikke funnet")
try:
password_is_valid = pwd_context.verify(request.password, admin["password_hash"])
except Exception as exc:
raise HTTPException(status_code=500, detail="Kunne ikke verifisere passordet") from exc
if not password_is_valid:
raise HTTPException(status_code=401, detail="Feil passord")
if not admin["otp_secret"]:
raise HTTPException(status_code=400, detail="Denne kontoen har ikke 2FA-nøkkel ennå")
issuer_name = "TeeOff.no"
account_name = admin["email"] or admin["username"]
provisioning_uri = pyotp.TOTP(admin["otp_secret"]).provisioning_uri(
name=account_name,
issuer_name=issuer_name,
)
return {
"issuer": issuer_name,
"account_name": account_name,
"otp_secret": admin["otp_secret"],
"provisioning_uri": provisioning_uri,
"qr_svg": generate_totp_qr_svg(provisioning_uri),
}
2026-03-05 05:18:03 +01:00
@app.post("/api/admin/run-scraper")
2026-04-11 09:54:54 +02:00
async def run_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
2026-04-10 18:37:33 +02:00
"""Legger banestatus-skraping i en persistent jobbkø."""
print(f"📡 API mottok forespørsel om å kjøre banestatus-skraping for IDer: {request.facility_ids}")
2026-04-11 09:54:54 +02:00
return await queue_scrape_job("banestatus", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
2026-04-10 18:37:33 +02:00
2026-03-05 05:18:03 +01:00
2026-03-12 13:39:10 +01:00
@app.post("/api/admin/run-membership-scraper")
2026-04-11 09:54:54 +02:00
async def run_membership_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
2026-03-12 13:39:10 +01:00
"""Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
2026-04-11 09:54:54 +02:00
return await queue_scrape_job("medlemskap", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
2026-03-12 13:39:10 +01:00
2026-02-27 09:35:30 +01:00
@app.get("/api/health")
async def health_check():
2026-03-02 09:56:37 +01:00
"""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!"}
2026-03-12 13:39:10 +01:00
@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"}
@app.post("/api/admin/run-greenfee-scraper")
2026-04-11 09:54:54 +02:00
async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
"""Tar imot IDer for greenfeeskraping og legger jobben i kø."""
2026-04-10 18:37:33 +02:00
print(f"📡 API mottok forespørsel om greenfee-skraping for IDer: {request.facility_ids}")
2026-04-11 09:54:54 +02:00
return await queue_scrape_job("greenfee", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
# --- 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"}
@app.post("/api/admin/run-vtg-scraper")
2026-04-11 09:54:54 +02:00
async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
"""Tar imot IDer for VTG-skraping og legger jobben i kø."""
2026-04-10 18:37:33 +02:00
print(f"📡 API mottok forespørsel om VTG-skraping for IDer: {request.facility_ids}")
2026-04-11 09:54:54 +02:00
return await queue_scrape_job("vtg", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
2026-03-02 09:56:37 +01:00
if __name__ == "__main__":
import uvicorn
2026-04-10 18:37:33 +02:00
uvicorn.run(app, host="0.0.0.0", port=8000)