Nye-TeeOff/backend/main.py

4450 lines
158 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 asyncio
2026-02-26 09:20:51 +01:00
import asyncpg
import json
import pyotp
import os
2026-04-13 15:29:43 +02:00
import re
import secrets
import hashlib
import smtplib
2026-04-26 11:29:35 +02:00
import time
2026-04-26 09:52:05 +02:00
import unicodedata
from datetime import datetime, date, timedelta
from email.message import EmailMessage
2026-04-13 15:29:43 +02:00
from pathlib import Path
from jose import jwt, JWTError
from passlib.context import CryptContext
2026-04-11 09:54:54 +02:00
import qrcode
import qrcode.image.svg
import httpx
from pydantic import BaseModel, Field
from typing import Optional, List, Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
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-04-26 09:52:05 +02:00
from course_status_history import (
ensure_course_status_history_table,
get_oslo_today,
list_course_status_history,
log_course_status_change,
)
from env_config import get_database_url, get_required_env
from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows
2026-04-19 10:24:33 +02:00
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
2026-02-26 09:20:51 +01:00
2026-02-27 09:35:30 +01:00
# --- KONFIGURASJON ---
DB_URL = get_database_url()
SECRET_KEY = get_required_env("JWT_SECRET")
ALGORITHM = "HS256"
PUBLIC_SESSION_SECRET = os.getenv("PUBLIC_SESSION_SECRET", SECRET_KEY)
PUBLIC_SESSION_COOKIE = "teeoff_user_session"
PUBLIC_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30
PUBLIC_COMMENT_STATUSES = {"pending", "published", "rejected", "deleted"}
PUBLIC_COMMENT_DEFAULT_STATUS = (
os.getenv("PUBLIC_COMMENT_DEFAULT_STATUS", "published").strip().lower()
if os.getenv("PUBLIC_COMMENT_DEFAULT_STATUS", "published").strip().lower() in PUBLIC_COMMENT_STATUSES
else "published"
)
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "").strip()
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "").strip()
GOOGLE_OAUTH_SCOPES = os.getenv("GOOGLE_OAUTH_SCOPES", "openid email profile").strip()
GOOGLE_DISCOVERY_URL = os.getenv(
"GOOGLE_DISCOVERY_URL",
"https://accounts.google.com/.well-known/openid-configuration",
).strip()
SMTP_SERVER = os.getenv("SMTP_SERVER", "").strip()
SMTP_PORT = os.getenv("SMTP_PORT", "").strip()
SMTP_USER = os.getenv("SMTP_USER", "").strip()
SMTP_PASS = os.getenv("SMTP_PASS", "").strip()
PUBLIC_FROM_EMAIL = os.getenv("PUBLIC_FROM_EMAIL", SMTP_USER).strip()
CONTACT_FORM_TO_EMAIL = os.getenv("CONTACT_FORM_TO_EMAIL", "teeoff@teeoff.no").strip()
2026-04-18 09:00:16 +02:00
COMMENT_NOTIFICATION_TO_EMAIL = os.getenv(
"COMMENT_NOTIFICATION_TO_EMAIL",
CONTACT_FORM_TO_EMAIL,
).strip()
INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip()
INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip()
INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").strip()
2026-03-02 09:56:37 +01:00
2026-04-26 11:29:35 +02:00
PUBLIC_FACILITIES_CACHE_TTLS = {
"search": 900,
"home": 900,
"place": 3600,
"membership": 1800,
"vtg": 1800,
"clubnumbers": 3600,
"sitemap": 3600,
"aliases": 3600,
"default": 300,
}
PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS = 900
PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS = 3600
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
2026-02-26 09:20:51 +01:00
2026-04-11 09:54:54 +02:00
def get_int_env(name: str, default: int) -> int:
raw = str(os.getenv(name, str(default))).strip()
try:
return int(raw)
except ValueError:
return default
2026-04-17 09:25:32 +02:00
ADMIN_SESSION_MAX_AGE_SECONDS = get_int_env("ADMIN_SESSION_MAX_AGE_SECONDS", 60 * 60 * 12)
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS = get_int_env("ADMIN_REMEMBER_ME_MAX_AGE_SECONDS", 60 * 60 * 24 * 30)
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20)
PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60)
CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS = get_int_env("CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS", 60 * 60)
CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS = get_int_env("CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS", 3)
CONTACT_FORM_MIN_FILL_SECONDS = get_int_env("CONTACT_FORM_MIN_FILL_SECONDS", 5)
def is_google_login_configured() -> bool:
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
def is_magic_link_configured() -> bool:
return bool(SMTP_SERVER and SMTP_PORT and SMTP_USER and SMTP_PASS and PUBLIC_FROM_EMAIL)
def is_contact_form_configured() -> bool:
return is_magic_link_configured() and bool(CONTACT_FORM_TO_EMAIL)
def get_public_auth_config() -> dict[str, Any]:
google_enabled = is_google_login_configured()
magic_link_enabled = is_magic_link_configured()
return {
"configured": google_enabled or magic_link_enabled,
"google": google_enabled,
"magic_link": magic_link_enabled,
}
2026-04-26 11:29:35 +02:00
def initialize_public_api_caches() -> None:
app.state.public_facilities_cache = {}
app.state.public_facility_detail_cache = {}
app.state.public_place_page_cache = {}
def get_public_facilities_cache_ttl(view: str | None) -> int:
normalized_view = (view or "").strip().lower()
if normalized_view in PUBLIC_FACILITIES_CACHE_TTLS:
return PUBLIC_FACILITIES_CACHE_TTLS[normalized_view]
return PUBLIC_FACILITIES_CACHE_TTLS["default"]
def read_public_cache_entry(cache_store: dict[str, tuple[float, Any]], cache_key: str) -> Any | None:
entry = cache_store.get(cache_key)
if not entry:
return None
expires_at, payload = entry
if expires_at <= time.monotonic():
cache_store.pop(cache_key, None)
return None
return payload
def write_public_cache_entry(
cache_store: dict[str, tuple[float, Any]],
cache_key: str,
payload: Any,
ttl_seconds: int,
) -> Any:
cache_store[cache_key] = (time.monotonic() + max(1, ttl_seconds), payload)
return payload
def apply_public_cache_headers(response: Response, ttl_seconds: int) -> None:
ttl = max(60, int(ttl_seconds))
response.headers["Cache-Control"] = f"public, max-age=60, s-maxage={ttl}, stale-while-revalidate=60"
def invalidate_public_api_caches(*, include_place_pages: bool = False) -> None:
facilities_cache = getattr(app.state, "public_facilities_cache", None)
if isinstance(facilities_cache, dict):
facilities_cache.clear()
detail_cache = getattr(app.state, "public_facility_detail_cache", None)
if isinstance(detail_cache, dict):
detail_cache.clear()
if include_place_pages:
place_page_cache = getattr(app.state, "public_place_page_cache", None)
if isinstance(place_page_cache, dict):
place_page_cache.clear()
2026-04-18 09:00:16 +02:00
def get_configured_public_base_url() -> str:
for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"):
configured = os.getenv(env_name, "").strip().rstrip("/")
if configured:
return configured
return ""
def build_public_base_url(request: Request) -> str:
2026-04-18 09:00:16 +02:00
configured = get_configured_public_base_url()
if configured:
return configured
forwarded_proto = request.headers.get("x-forwarded-proto")
forwarded_host = request.headers.get("x-forwarded-host")
if forwarded_proto and forwarded_host:
return f"{forwarded_proto}://{forwarded_host}".rstrip("/")
return str(request.base_url).rstrip("/")
def build_google_redirect_uri(request: Request) -> str:
return f"{build_public_base_url(request)}/api/public/auth/google/callback"
2026-04-18 09:00:16 +02:00
def build_absolute_public_url(path: str) -> str | None:
base_url = get_configured_public_base_url()
if not base_url:
return None
if not path:
return base_url
return f"{base_url}{path if path.startswith('/') else f'/{path}'}"
def dedupe_strings(values: list[str]) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for value in values:
normalized = str(value or "").strip()
if not normalized or normalized in seen:
continue
deduped.append(normalized)
seen.add(normalized)
return deduped
def build_article_public_url(section: str | None, slug: str | None) -> str | None:
normalized_section = str(section or "").strip().lower()
normalized_slug = str(slug or "").strip()
if normalized_section not in {"banebesok", "meninger"} or not normalized_slug:
return None
return build_absolute_public_url(f"/{normalized_section}/{normalized_slug}")
def build_facility_public_url(slug: str | None) -> str | None:
normalized_slug = str(slug or "").strip()
if not normalized_slug:
return None
return build_absolute_public_url(f"/golfbaner/{normalized_slug}")
def collect_article_indexnow_urls(
previous_article: dict[str, Any] | None = None,
current_article: dict[str, Any] | None = None,
) -> list[str]:
urls: list[str] = []
def add_section_url(section: str | None) -> None:
normalized_section = str(section or "").strip().lower()
if normalized_section not in {"banebesok", "meninger"}:
return
section_url = build_absolute_public_url(f"/{normalized_section}")
if section_url:
urls.append(section_url)
def add_article_url(article: dict[str, Any] | None, include_when_status: str) -> None:
if not article:
return
if str(article.get("status") or "").strip().lower() != include_when_status:
return
article_url = build_article_public_url(article.get("section"), article.get("slug"))
if article_url:
urls.append(article_url)
add_section_url(article.get("section"))
add_article_url(previous_article, "published")
add_article_url(current_article, "published")
return dedupe_strings(urls)
def collect_facility_indexnow_urls(slugs: list[str], extra_paths: list[str] | None = None) -> list[str]:
urls: list[str] = []
for slug in slugs:
facility_url = build_facility_public_url(slug)
if facility_url:
urls.append(facility_url)
for path in extra_paths or []:
listing_url = build_absolute_public_url(path)
if listing_url:
urls.append(listing_url)
return dedupe_strings(urls)
def get_indexnow_key_location() -> str | None:
configured = INDEXNOW_KEY_LOCATION.strip()
if configured:
return configured
if not INDEXNOW_KEY:
return None
return build_absolute_public_url(f"/{INDEXNOW_KEY}.txt")
def normalize_indexnow_urls(urls: list[str]) -> list[str]:
base_url = get_configured_public_base_url()
if not base_url:
return []
site = urlsplit(base_url)
allowed_host = site.netloc.strip().lower()
if not allowed_host:
return []
normalized_urls: list[str] = []
seen: set[str] = set()
for value in urls:
candidate = str(value or "").strip()
if not candidate:
continue
parsed = urlsplit(candidate)
if parsed.scheme not in {"http", "https"}:
continue
if parsed.netloc.strip().lower() != allowed_host:
continue
normalized = urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", parsed.query, ""))
if normalized in seen:
continue
seen.add(normalized)
normalized_urls.append(normalized)
return normalized_urls
async def submit_indexnow_urls(urls: list[str], reason: str) -> None:
if not INDEXNOW_KEY:
return
normalized_urls = normalize_indexnow_urls(urls)
base_url = get_configured_public_base_url()
key_location = get_indexnow_key_location()
host = urlsplit(base_url).netloc.strip() if base_url else ""
if not normalized_urls or not host or not key_location:
return
payload = {
"host": host,
"key": INDEXNOW_KEY,
"keyLocation": key_location,
"urlList": normalized_urls,
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(INDEXNOW_ENDPOINT, json=payload)
except Exception as exc:
print(f"IndexNow-feil ({reason}): {exc}")
return
if response.status_code >= 400:
body_preview = response.text.strip().replace("\n", " ")
print(f"IndexNow avvist ({reason}): HTTP {response.status_code} {body_preview[:300]}")
return
print(f"IndexNow sendt ({reason}): {len(normalized_urls)} URL-er, HTTP {response.status_code}")
def schedule_indexnow_submission(urls: list[str], reason: str) -> None:
if not INDEXNOW_KEY:
return
normalized_urls = normalize_indexnow_urls(urls)
if not normalized_urls:
return
asyncio.create_task(submit_indexnow_urls(normalized_urls, reason))
def should_use_secure_cookies(request: Request) -> bool:
configured = os.getenv("PUBLIC_BASE_URL", "").strip().lower()
if configured.startswith("https://"):
return True
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
if forwarded_proto:
return forwarded_proto == "https"
return request.url.scheme == "https"
async def get_google_discovery_document() -> dict[str, Any]:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.get(GOOGLE_DISCOVERY_URL)
response.raise_for_status()
return response.json()
def normalize_return_to_path(value: str | None) -> str:
candidate = str(value or "/").strip()
if not candidate.startswith("/"):
return "/"
if candidate.startswith("//"):
return "/"
return candidate or "/"
def append_query_param(url: str, key: str, value: str) -> str:
parsed = urlsplit(url)
query_params = dict(parse_qsl(parsed.query, keep_blank_values=True))
query_params[key] = value
return urlunsplit(
(
parsed.scheme,
parsed.netloc,
parsed.path,
urlencode(query_params),
parsed.fragment,
)
)
def create_public_session_token(user_id: int) -> str:
expires_at = datetime.utcnow() + timedelta(seconds=PUBLIC_SESSION_MAX_AGE_SECONDS)
return jwt.encode(
{
"sub": f"public:{user_id}",
"uid": user_id,
"exp": expires_at,
},
PUBLIC_SESSION_SECRET,
algorithm=ALGORITHM,
)
def decode_public_session_token(token: str) -> dict[str, Any]:
try:
payload = jwt.decode(token, PUBLIC_SESSION_SECRET, algorithms=[ALGORITHM])
if not payload.get("uid"):
raise JWTError()
return payload
except JWTError as exc:
raise HTTPException(status_code=401, detail="Ugyldig eller utløpt brukerøkt") from exc
def hash_request_ip(request: Request) -> str | None:
forwarded_for = request.headers.get("x-forwarded-for", "")
ip = forwarded_for.split(",")[0].strip() if forwarded_for else (request.client.host if request.client else "")
if not ip:
return None
return hashlib.sha256(ip.encode("utf-8")).hexdigest()
def hash_magic_link_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def normalize_public_email(email: str | None) -> str | None:
normalized = str(email or "").strip().lower()
return normalized or None
async def send_magic_link_email(email_address: str, login_url: str) -> None:
subject = "Logg inn på TeeOff"
body = (
"Hei,\n\n"
"Klikk på lenken under for å logge inn på TeeOff og kommentere:\n\n"
f"{login_url}\n\n"
f"Lenken utløper om {PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} minutter.\n\n"
"Hvis du ikke ba om denne lenken, kan du se bort fra e-posten.\n"
)
def _send() -> None:
message = EmailMessage()
message["From"] = PUBLIC_FROM_EMAIL
message["To"] = email_address
message["Subject"] = subject
message.set_content(body)
with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server:
server.login(SMTP_USER, SMTP_PASS)
server.send_message(message)
await asyncio.to_thread(_send)
async def send_contact_form_email(
*,
sender_name: str,
sender_email: str,
topic: str,
message: str,
ip_hash: str | None,
) -> None:
subject = f"[TeeOff Kontakt] {topic}"
body = (
"Ny melding fra kontaktskjemaet på TeeOff.no\n\n"
f"Navn: {sender_name}\n"
f"E-post: {sender_email}\n"
f"Emne: {topic}\n"
f"IP-hash: {ip_hash or 'ukjent'}\n\n"
"Melding:\n"
f"{message.strip()}\n"
)
def _send() -> None:
mail = EmailMessage()
mail["From"] = PUBLIC_FROM_EMAIL
mail["To"] = CONTACT_FORM_TO_EMAIL
mail["Reply-To"] = sender_email
mail["Subject"] = subject
mail.set_content(body)
with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server:
server.login(SMTP_USER, SMTP_PASS)
server.send_message(mail)
await asyncio.to_thread(_send)
2026-04-19 13:35:48 +02:00
async def send_facility_feedback_email(
*,
facility_name: str,
facility_slug: str,
sender_name: str,
sender_email: str,
message: str,
ip_hash: str | None,
) -> None:
subject = f"[TeeOff Golfbane] Vedrørende {facility_name}"
facility_url = f"https://teeoff.no/golfbaner/{facility_slug}" if facility_slug else "ukjent"
body = (
"Ny tilbakemelding fra baneprofil på TeeOff.no\n\n"
f"Golfanlegg: {facility_name}\n"
f"Side: {facility_url}\n"
f"Navn: {sender_name}\n"
f"E-post: {sender_email}\n"
f"IP-hash: {ip_hash or 'ukjent'}\n\n"
"Melding:\n"
f"{message.strip()}\n"
)
def _send() -> None:
mail = EmailMessage()
mail["From"] = PUBLIC_FROM_EMAIL
mail["To"] = CONTACT_FORM_TO_EMAIL
mail["Reply-To"] = sender_email
mail["Subject"] = subject
mail.set_content(body)
with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server:
server.login(SMTP_USER, SMTP_PASS)
server.send_message(mail)
await asyncio.to_thread(_send)
2026-04-18 09:00:16 +02:00
async def send_comment_notification_email(
*,
article_title: str,
article_url: str,
article_section: str,
comment_body: str,
comment_status: str,
commenter_name: str,
commenter_email: str | None,
parent_author_name: str | None = None,
) -> None:
if not (is_magic_link_configured() and COMMENT_NOTIFICATION_TO_EMAIL):
return
subject = f"[TeeOff Kommentar] {article_title}"
body = (
"Ny kommentar på TeeOff.no\n\n"
f"Seksjon: {article_section}\n"
f"Artikkel: {article_title}\n"
f"Lenke: {article_url}\n"
f"Status: {comment_status}\n"
f"Forfatter: {commenter_name}\n"
f"E-post: {commenter_email or 'ikke tilgjengelig'}\n"
f"Svar på kommentar fra: {parent_author_name or 'ingen, dette er toppnivå'}\n\n"
"Kommentar:\n"
f"{comment_body.strip()}\n"
)
def _send() -> None:
mail = EmailMessage()
mail["From"] = PUBLIC_FROM_EMAIL
mail["To"] = COMMENT_NOTIFICATION_TO_EMAIL
if commenter_email:
mail["Reply-To"] = commenter_email
mail["Subject"] = subject
mail.set_content(body)
with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server:
server.login(SMTP_USER, SMTP_PASS)
server.send_message(mail)
await asyncio.to_thread(_send)
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
2026-04-21 07:21:46 +02:00
class FacilityVisibilityRequest(BaseModel):
is_published: bool
2026-04-24 09:17:14 +02:00
class PlacePageUpsertRequest(BaseModel):
factbox_intro_html: Optional[str] = ""
class GreenfeeApproval(BaseModel):
facility_id: int
greenfee: List[dict]
2026-04-15 08:15:53 +02:00
class GolfpakkerApproval(BaseModel):
facility_id: int
golfpakker: 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 VtgContentApproval(BaseModel):
facility_id: int
vtg_pris: int | None
vtg_beskrivelse: str | None
class BulkVtgContentRequest(BaseModel):
approvals: List[VtgContentApproval]
class VtgCoursesApproval(BaseModel):
facility_id: int
vtg_datoer: List[dict] | None
class BulkVtgCoursesRequest(BaseModel):
approvals: List[VtgCoursesApproval]
2026-04-15 08:15:53 +02:00
class BulkGolfpakkerRequest(BaseModel):
approvals: List[GolfpakkerApproval]
2026-04-11 09:54:54 +02:00
class AdminPasswordConfirm(BaseModel):
password: str
2026-04-13 15:29:43 +02:00
class ArticleUpsertRequest(BaseModel):
section: Optional[str] = "banebesok"
2026-04-13 15:29:43 +02:00
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]]] = []
media_gallery: Optional[List[dict[str, Any]]] = []
featured_media_id: Optional[str] = None
2026-04-13 15:29:43 +02:00
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
class PublicCommentCreateRequest(BaseModel):
body: str
2026-04-18 09:00:16 +02:00
parent_id: Optional[int] = None
class PublicMagicLinkRequest(BaseModel):
email: str
return_to: Optional[str] = "/"
class FacilityRatingUpsertRequest(BaseModel):
quality_rating: int = Field(ge=1, le=5)
conditions_rating: int = Field(ge=1, le=5)
hospitality_rating: int = Field(ge=1, le=5)
class PublicContactFormRequest(BaseModel):
name: str
email: str
topic: str
message: str
website: Optional[str] = ""
started_at: Optional[int] = None
2026-04-19 13:35:48 +02:00
class PublicFacilityFeedbackRequest(BaseModel):
facility_id: int
name: str
email: str
message: str
website: Optional[str] = ""
started_at: Optional[int] = None
2026-04-26 09:52:05 +02:00
LEGACY_FACILITY_FIELD_ALIASES = {
'vtg_presentasjon': 'vtg_beskrivelse',
'vtg_kursdatoer': 'vtg_datoer',
}
FACILITY_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',
'image_url', 'logo_url', 'front_image_url', 'gallery',
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data',
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
'vtg_updated_at', 'vtg_draft', 'vtg_content_draft', 'vtg_courses_draft',
'vtg_content_updated_at', 'vtg_courses_updated_at', 'footnote_updated_at', 'is_published',
'golfpakker_draft', 'golfpakker_updated_at'
]
FACILITY_MEMBERSHIP_FIELDS = {
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at'
}
FACILITY_VTG_CONTENT_FIELDS = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris'}
FACILITY_VTG_COURSE_FIELDS = {'vtg_datoer'}
FACILITY_VTG_FIELDS = FACILITY_VTG_CONTENT_FIELDS | FACILITY_VTG_COURSE_FIELDS | {
'vtg_updated_at',
'vtg_content_updated_at',
'vtg_courses_updated_at',
}
2026-04-26 11:29:35 +02:00
NON_PUBLIC_FACILITY_FIELDS = {
'membership_draft',
'greenfee_draft',
'vtg_draft',
'vtg_content_draft',
'vtg_courses_draft',
'golfpakker_draft',
'scrape_method',
'scrape_status_url',
'scrape_status_selector',
'ai_instruction',
}
FACILITY_VIEW_SEARCH_FIELDS = {
'id', 'slug', 'name', 'architect', 'description', 'city', 'county', 'banetype',
'image_url', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker',
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'footnote', 'footnote_updated_at',
'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer',
'course_statuses', 'weather_forecast',
}
FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | {
'greenfee', 'standard_medlemskap', 'total_hole_count', 'hole_par_counts',
'shortest_hole_meters', 'longest_hole_meters',
}
FACILITY_VIEW_MEMBERSHIP_FIELDS = {
'id', 'slug', 'name', 'city', 'county', 'medlemskap_url', 'membership_updated_at',
'standard_medlemskap_kommentarer', 'navn_standard_medlemskap', 'standard_medlemskap',
'navn_rimeligste_alternativ', 'rimeligste_alternativ',
}
FACILITY_VIEW_VTG_FIELDS = {
'id', 'slug', 'name', 'city', 'county', 'lat', 'lng', 'status_updated_at',
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'vtg_datoer', 'vtg_updated_at',
}
FACILITY_VIEW_CLUBNUMBERS_FIELDS = {'id', 'slug', 'name', 'city', 'county', 'ngf_number'}
FACILITY_VIEW_SITEMAP_FIELDS = {'slug', 'status_updated_at', 'vtg_updated_at'}
FACILITY_VIEW_ALIASES_FIELDS = {'slug', 'name'}
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',
2026-04-15 08:15:53 +02:00
'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at',
'golfpakker_updated_at', 'vtg_content_updated_at', 'vtg_courses_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',
2026-04-19 10:24:33 +02:00
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer',
'weather_forecast', 'vtg_courses_draft'
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', 'golfpakker_draft', 'hole_par_counts',
'vtg_content_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-04-15 08:15:53 +02:00
2026-02-27 09:35:30 +01:00
return d
2026-04-11 09:54:54 +02:00
2026-04-26 11:29:35 +02:00
def sanitize_public_facility_row(row: Any, *, fields: set[str] | None = None) -> dict[str, Any] | None:
formatted = format_row(row)
if formatted is None:
return None
for field in NON_PUBLIC_FACILITY_FIELDS:
formatted.pop(field, None)
if fields is not None:
formatted = {
key: value
for key, value in formatted.items()
if key in fields
}
return formatted
def prepare_vtg_content_draft_payload(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
payload: dict[str, Any] = {}
if "foreslatt_vtg_pris" in value:
payload["foreslatt_vtg_pris"] = value.get("foreslatt_vtg_pris")
if "foreslatt_vtg_beskrivelse" in value:
payload["foreslatt_vtg_beskrivelse"] = value.get("foreslatt_vtg_beskrivelse")
ai_reason = str(value.get("ai_begrunnelse") or "").strip()
if ai_reason:
payload["ai_begrunnelse"] = ai_reason
return payload
def prepare_vtg_course_draft_payload(value: Any) -> list[dict[str, Any]] | None:
if not isinstance(value, dict) or "foreslatt_vtg_datoer" not in value:
return None
return filter_upcoming_courses(value.get("foreslatt_vtg_datoer"))
async def replace_facility_vtg_courses(conn, facility_id: int, rows: Any) -> list[dict[str, Any]]:
normalized_rows = filter_upcoming_courses(rows)
await conn.execute("DELETE FROM facility_vtg_courses WHERE facility_id = $1", facility_id)
if normalized_rows:
await conn.executemany(
"""
INSERT INTO facility_vtg_courses (
facility_id,
display_label,
status,
start_date,
end_date,
sort_order
) VALUES ($1, $2, $3, $4, $5, $6)
""",
[
(
facility_id,
row.get("dato"),
row.get("status"),
row.get("start_date"),
row.get("end_date"),
int(row.get("sort_order") or index),
)
for index, row in enumerate(normalized_rows)
],
)
legacy_rows = json.dumps(
[
{
"dato": row.get("dato"),
"status": row.get("status"),
"start_date": row.get("start_date"),
"end_date": row.get("end_date"),
}
for row in normalized_rows
]
)
await conn.execute(
"UPDATE facilities SET vtg_datoer = $1::jsonb WHERE id = $2",
legacy_rows,
facility_id,
)
return normalized_rows
2026-04-24 09:17:14 +02:00
def format_place_page_row(row):
if row is None:
return None
d = dict(row)
for key in ["created_at", "updated_at"]:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
d["factbox_intro_html"] = str(d.get("factbox_intro_html") or "")
return d
2026-04-15 08:15:53 +02:00
def normalize_club_lookup_value(value: str | None) -> str:
text = str(value or "").strip().lower()
if not text:
return ""
text = text.replace("&", " og ")
text = re.sub(r"\bgk\b", " golfklubb ", text)
text = re.sub(r"\bgs\b", " golfsenter ", text)
text = re.sub(r"[^a-z0-9æøå]+", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
async def resolve_cooperating_club_slugs(
conn,
suggested_names: list[str] | None,
*,
exclude_facility_id: int | None = None,
) -> list[str]:
cleaned_names = [str(name or "").strip() for name in (suggested_names or []) if str(name or "").strip()]
if not cleaned_names:
return []
rows = await conn.fetch("SELECT id, name, slug FROM facilities ORDER BY name ASC")
candidates: list[tuple[str, str]] = []
for row in rows:
facility_id = int(row["id"])
if exclude_facility_id and facility_id == exclude_facility_id:
continue
name = str(row["name"] or "").strip()
slug = str(row["slug"] or "").strip()
normalized_candidates = " ".join(
part for part in [
normalize_club_lookup_value(name),
normalize_club_lookup_value(slug.replace("-", " ")),
] if part
).strip()
candidates.append((slug, normalized_candidates))
resolved_slugs: list[str] = []
seen_slugs: set[str] = set()
for suggested_name in cleaned_names:
normalized_suggestion = normalize_club_lookup_value(suggested_name)
if not normalized_suggestion:
continue
exact_matches = [candidate for candidate in candidates if candidate[1] == normalized_suggestion]
if len(exact_matches) == 1:
slug = exact_matches[0][0]
if slug and slug not in seen_slugs:
resolved_slugs.append(slug)
seen_slugs.add(slug)
continue
partial_matches = [
candidate
for candidate in candidates
if normalized_suggestion in candidate[1] or candidate[1] in normalized_suggestion
]
if len(partial_matches) == 1:
slug = partial_matches[0][0]
if slug and slug not in seen_slugs:
resolved_slugs.append(slug)
seen_slugs.add(slug)
return resolved_slugs
2026-04-17 09:25:32 +02:00
async def get_table_columns(conn, table_name: str, schema_name: str = "public") -> set[str]:
rows = await conn.fetch(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
""",
schema_name,
table_name,
)
return {str(row["column_name"]) for row in rows}
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()
section = str(data.get("section") or "banebesok").strip().lower()
data["section"] = section if section in {"banebesok", "meninger"} else "banebesok"
2026-04-13 15:29:43 +02:00
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"] = []
media_gallery = data.get("media_gallery")
if media_gallery is None:
data["media_gallery"] = []
elif isinstance(media_gallery, str):
try:
data["media_gallery"] = json.loads(media_gallery)
except Exception:
data["media_gallery"] = []
elif not isinstance(media_gallery, list):
data["media_gallery"] = []
if not data["media_gallery"] and data["hero_images"]:
data["media_gallery"] = [
{
"id": build_article_media_id("image", image.get("src") or ""),
"type": "image",
"src": image.get("src") or "",
"alt": image.get("alt") or "",
"caption": image.get("caption") or "",
"poster": "",
}
for image in data["hero_images"]
if isinstance(image, dict) and str(image.get("src") or "").strip()
]
if not data.get("featured_media_id") and data["media_gallery"]:
first_image = next(
(
item
for item in data["media_gallery"]
if isinstance(item, dict) and str(item.get("type") or "image").strip().lower() == "image"
),
None,
)
if first_image:
data["featured_media_id"] = str(first_image.get("id") or "").strip() or None
2026-04-13 15:29:43 +02:00
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 normalize_article_section(section: str | None, allow_all: bool = False) -> str:
normalized = str(section or "banebesok").strip().lower()
valid_sections = {"banebesok", "meninger"}
if allow_all:
valid_sections = valid_sections | {"all"}
if normalized not in valid_sections:
raise HTTPException(
status_code=400,
detail="Ugyldig artikkelseksjon. Bruk 'banebesok' eller 'meninger'.",
)
return normalized
2026-04-13 15:29:43 +02:00
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
2026-04-21 07:21:46 +02:00
def parse_optional_int(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
trimmed = str(value).strip()
if not trimmed:
return None
try:
return int(float(trimmed.replace(",", ".")))
except ValueError:
return None
2026-04-13 15:29:43 +02:00
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": normalize_article_media_url(src),
2026-04-13 15:29:43 +02:00
"alt": str(item.get("alt") or "").strip(),
"caption": str(item.get("caption") or "").strip(),
}
)
return sanitized
LEGACY_ARTICLE_MEDIA_PATTERN = re.compile(
r"^https?://(?:www\.)?(?:teeoff\.no|nye\.teeoff\.no|wp\.teeoff\.no)(?P<path>/(?:wp-content/uploads|uploads/articles)/.+)$",
re.IGNORECASE,
)
YOUTUBE_THUMBNAIL_PATTERN = re.compile(
r"^https?://i\.ytimg\.com/vi(?:_webp)?/(?P<video_id>[^/]+)/",
re.IGNORECASE,
)
def normalize_article_media_url(value: str | None) -> str:
trimmed = str(value or "").strip()
if not trimmed:
return ""
legacy_match = LEGACY_ARTICLE_MEDIA_PATTERN.match(trimmed)
if legacy_match:
path = legacy_match.group("path")
if path.startswith("/wp-content/uploads/"):
return f"https://wp.teeoff.no{path}"
return path
return trimmed
def build_article_media_id(media_type: str, src: str) -> str:
digest = hashlib.sha1(f"{media_type}:{src}".encode("utf-8")).hexdigest()[:12]
return f"{media_type}-{digest}"
def sanitize_article_media(value: Any, title: str | None = None) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
sanitized: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
fallback_text = str(title or "").strip()
for item in value:
if not isinstance(item, dict):
continue
media_type = str(item.get("type") or "image").strip().lower()
if media_type not in {"image", "video"}:
continue
src = normalize_article_media_url(item.get("src"))
if not src:
continue
dedupe_key = (media_type, src)
if dedupe_key in seen:
continue
seen.add(dedupe_key)
poster = normalize_article_media_url(item.get("poster"))
alt = str(item.get("alt") or "").strip()
caption = str(item.get("caption") or "").strip()
media_id = str(item.get("id") or "").strip() or build_article_media_id(media_type, src)
sanitized.append(
{
"id": media_id,
"type": media_type,
"src": src,
"alt": alt or fallback_text,
"caption": caption or alt or fallback_text,
"poster": poster,
}
)
return sanitized
def build_media_gallery_from_hero_images(hero_images: list[dict[str, str]]) -> list[dict[str, str]]:
return [
{
"id": build_article_media_id("image", image["src"]),
"type": "image",
"src": image["src"],
"alt": image.get("alt") or "",
"caption": image.get("caption") or "",
"poster": "",
}
for image in hero_images
if image.get("src")
]
def sanitize_featured_media_id(featured_media_id: str | None, media_gallery: list[dict[str, str]]) -> str | None:
candidate = str(featured_media_id or "").strip()
if candidate and any(item.get("id") == candidate and item.get("type") == "image" for item in media_gallery):
return candidate
for item in media_gallery:
if item.get("type") == "image":
return item.get("id")
return None
2026-04-26 09:52:05 +02:00
def normalize_facility_slug(value: Any) -> str:
normalized = unicodedata.normalize("NFKD", str(value or "").strip().lower())
normalized = "".join(char for char in normalized if not unicodedata.combining(char))
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
return normalized.strip("-")
def apply_legacy_facility_field_aliases(data: dict[str, Any] | None) -> dict[str, Any]:
normalized = dict(data or {})
for legacy_field, canonical_field in LEGACY_FACILITY_FIELD_ALIASES.items():
if legacy_field in normalized and canonical_field not in normalized:
normalized[canonical_field] = normalized[legacy_field]
return normalized
def schedule_facility_indexnow_submission_for_fields(
facility_slug: str,
changed_field_names: set[str],
reason: str,
):
extra_paths = ["/golfbaner"]
if changed_field_names & FACILITY_MEMBERSHIP_FIELDS:
extra_paths.append("/medlemskap")
if changed_field_names & FACILITY_VTG_FIELDS:
extra_paths.append("/vtg")
schedule_indexnow_submission(
collect_facility_indexnow_urls([facility_slug], extra_paths=extra_paths),
reason=reason,
)
async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tuple[str, set[str]]:
normalized_data = apply_legacy_facility_field_aliases(data)
update_data = {k: v for k, v in normalized_data.items() if k in FACILITY_ALLOWED_FIELDS}
changed_field_names = set(update_data.keys())
facility_slug = str(
await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
).strip()
if not facility_slug:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
facility_columns = await get_table_columns(conn, "facilities")
update_data = {k: v for k, v in update_data.items() if k in facility_columns}
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 = []
date_fields = [
'membership_updated_at',
'greenfee_updated_at',
'vtg_updated_at',
'vtg_content_updated_at',
'vtg_courses_updated_at',
'status_updated_at',
'footnote_updated_at',
'golfpakker_updated_at'
]
if changed_field_names & FACILITY_VTG_CONTENT_FIELDS:
vtg_content_ts = datetime.utcnow()
update_data.setdefault('vtg_content_updated_at', vtg_content_ts)
update_data.setdefault('vtg_updated_at', vtg_content_ts)
if changed_field_names & FACILITY_VTG_COURSE_FIELDS:
vtg_course_ts = datetime.utcnow()
update_data.setdefault('vtg_courses_updated_at', vtg_course_ts)
update_data.setdefault('vtg_updated_at', vtg_course_ts)
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}")
if v == "" or v is None:
values.append(None)
else:
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)
if 'vtg_datoer' in update_data:
await replace_facility_vtg_courses(conn, facility_id, update_data.get('vtg_datoer'))
if 'courses' in normalized_data:
submitted_courses = [course for course in (normalized_data.get('courses') or []) if course]
normalized_courses: list[dict[str, Any]] = []
for course in submitted_courses:
normalized_course = dict(course)
normalized_course['is_main_course'] = bool(course.get('is_main_course'))
normalized_courses.append(normalized_course)
if normalized_courses:
if not any(course['is_main_course'] for course in normalized_courses):
normalized_courses[0]['is_main_course'] = True
else:
main_assigned = False
for course in normalized_courses:
if course['is_main_course'] and not main_assigned:
main_assigned = True
else:
course['is_main_course'] = False
retained_course_ids: list[int] = []
for course in normalized_courses:
course_id = course.get('id')
holes = [hole for hole in (course.get('holes') or []) if hole]
hole_count = len(holes) or None
course_par = parse_optional_int(course.get('par'))
course_length_meters = parse_optional_int(course.get('length_meters'))
valid_until_str = course.get('slope_valid_until')
if valid_until_str == "" or valid_until_str is None:
valid_until = None
else:
try:
date_part = str(valid_until_str).split('T')[0]
valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
except ValueError:
valid_until = None
tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
if course_id:
await conn.execute("""
UPDATE courses
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
slope_valid_until=$9
WHERE id=$10 AND facility_id=$11
""",
course.get('name'), hole_count, course_par, course_length_meters,
course.get('architect'), course.get('status'), course.get('is_main_course'),
tee_boxes_json, valid_until, course_id, facility_id)
else:
course_id = await conn.fetchval("""
INSERT INTO courses (
facility_id, name, holes, par, length_meters, architect,
status, is_main_course, tee_boxes, slope_valid_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING id
""",
facility_id, course.get('name'), hole_count, course_par, course_length_meters,
course.get('architect'), course.get('status'), course.get('is_main_course'),
tee_boxes_json, valid_until)
retained_course_ids.append(int(course_id))
retained_hole_ids: list[int] = []
for hole in holes:
hole_id = hole.get('id')
hole_number = parse_optional_int(hole.get('hole_number'))
hole_par = parse_optional_int(hole.get('par'))
hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
lengths_json = json.dumps(hole.get('lengths') or {})
if hole_id:
await conn.execute("""
UPDATE holes
SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
WHERE id=$5 AND course_id=$6
""",
hole_number, hole_par, hole_hcp_index,
lengths_json, hole_id, course_id)
else:
hole_id = await conn.fetchval("""
INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id
""",
course_id, hole_number, hole_par, hole_hcp_index,
lengths_json)
retained_hole_ids.append(int(hole_id))
if retained_hole_ids:
await conn.execute(
"DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
course_id,
retained_hole_ids,
)
else:
await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
if retained_course_ids:
await conn.execute(
"DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
facility_id,
retained_course_ids,
)
else:
await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id)
return facility_slug, changed_field_names
def build_hero_images_from_media_gallery(
media_gallery: list[dict[str, str]],
fallback_hero_images: list[dict[str, str]],
featured_media_id: str | None,
) -> list[dict[str, str]]:
image_media = [
{
"src": item["src"],
"alt": item.get("alt") or "",
"caption": item.get("caption") or item.get("alt") or "",
"id": item.get("id") or "",
}
for item in media_gallery
if item.get("type") == "image" and item.get("src")
]
if not image_media:
return fallback_hero_images
if featured_media_id:
featured_index = next(
(index for index, item in enumerate(image_media) if item.get("id") == featured_media_id),
None,
)
if featured_index is not None and featured_index > 0:
featured_item = image_media.pop(featured_index)
image_media.insert(0, featured_item)
return [
{
"src": item["src"],
"alt": item.get("alt") or "",
"caption": item.get("caption") or item.get("alt") or "",
}
for item in image_media
]
def format_public_user_row(row: Any) -> dict[str, Any] | None:
if row is None:
return None
data = dict(row)
for key in ["created_at", "updated_at", "last_login_at", "email_verified_at"]:
if isinstance(data.get(key), (date, datetime)):
data[key] = data[key].isoformat()
return {
"id": data.get("id"),
"vipps_sub": data.get("vipps_sub"),
"google_sub": data.get("google_sub"),
"full_name": data.get("full_name"),
"given_name": data.get("given_name"),
"family_name": data.get("family_name"),
"email": data.get("email"),
"phone_number": data.get("phone_number"),
"display_name": data.get("display_name"),
"is_blocked": bool(data.get("is_blocked")),
"email_verified_at": data.get("email_verified_at"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"last_login_at": data.get("last_login_at"),
}
def format_comment_row(row: Any) -> dict[str, Any] | None:
if row is None:
return None
data = dict(row)
for key in ["created_at", "updated_at", "published_at"]:
if isinstance(data.get(key), (date, datetime)):
data[key] = data[key].isoformat()
return {
"id": data.get("id"),
"article_id": data.get("article_id"),
"user_id": data.get("user_id"),
"parent_id": data.get("parent_id"),
"body": data.get("body"),
"status": data.get("status"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"published_at": data.get("published_at"),
"author": {
"display_name": data.get("display_name") or data.get("full_name") or "TeeOff-leser",
"given_name": data.get("given_name"),
},
"is_pending_for_viewer": data.get("status") == "pending",
}
async def find_public_user_by_email(conn, email: str | None):
normalized_email = normalize_public_email(email)
if not normalized_email:
return None
return await conn.fetchrow(
"""
SELECT *
FROM public_users
WHERE LOWER(email) = $1
ORDER BY id ASC
LIMIT 1
""",
normalized_email,
)
async def upsert_public_user_from_google_profile(conn, profile: dict[str, Any]):
google_sub = str(profile.get("sub") or "").strip()
if not google_sub:
raise HTTPException(status_code=500, detail="Google svarte uten bruker-ID.")
email = normalize_public_email(profile.get("email"))
if not email:
raise HTTPException(status_code=400, detail="Google-kontoen mangler e-postadresse.")
full_name = str(profile.get("name") or "").strip() or None
given_name = str(profile.get("given_name") or "").strip() or None
family_name = str(profile.get("family_name") or "").strip() or None
email_verified = bool(profile.get("email_verified"))
default_display_name = full_name or given_name or email.split("@")[0] or "TeeOff-leser"
existing = await conn.fetchrow("SELECT * FROM public_users WHERE google_sub = $1", google_sub)
if not existing:
existing = await find_public_user_by_email(conn, email)
if existing:
return await conn.fetchrow(
"""
UPDATE public_users
SET
google_sub = COALESCE(public_users.google_sub, $2),
email = $3,
full_name = $4,
given_name = $5,
family_name = $6,
display_name = COALESCE(public_users.display_name, $7),
email_verified_at = CASE
WHEN $8 THEN COALESCE(public_users.email_verified_at, NOW())
ELSE public_users.email_verified_at
END,
updated_at = NOW(),
last_login_at = NOW()
WHERE id = $1
RETURNING *
""",
existing["id"],
google_sub,
email,
full_name,
given_name,
family_name,
default_display_name,
email_verified,
)
return await conn.fetchrow(
"""
INSERT INTO public_users (
google_sub, email, full_name, given_name, family_name, display_name,
email_verified_at, last_login_at
) VALUES (
$1, $2, $3, $4, $5, $6,
CASE WHEN $7 THEN NOW() ELSE NULL END, NOW()
)
RETURNING *
""",
google_sub,
email,
full_name,
given_name,
family_name,
default_display_name,
email_verified,
)
async def get_or_create_public_user_by_email(conn, email: str):
normalized_email = normalize_public_email(email)
if not normalized_email:
raise HTTPException(status_code=400, detail="Ugyldig e-postadresse.")
existing = await find_public_user_by_email(conn, normalized_email)
if existing:
return existing
return await conn.fetchrow(
"""
INSERT INTO public_users (
email, display_name
) VALUES (
$1, $2
)
RETURNING *
""",
normalized_email,
normalized_email.split("@")[0] or "TeeOff-leser",
)
async def get_authenticated_public_user(request: Request) -> dict[str, Any] | None:
token = request.cookies.get(PUBLIC_SESSION_COOKIE)
if not token:
return None
try:
payload = decode_public_session_token(token)
except HTTPException:
return None
user_id = int(payload["uid"])
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM public_users WHERE id = $1", user_id)
if not row:
return None
user = format_public_user_row(row)
if user and user["is_blocked"]:
raise HTTPException(status_code=403, detail="Brukeren er blokkert fra å kommentere.")
return user
async def require_authenticated_public_user(request: Request) -> dict[str, Any]:
user = await get_authenticated_public_user(request)
if not user:
raise HTTPException(status_code=401, detail="Du må logge inn for å kommentere.")
return user
async def get_published_facility_by_slug(conn, slug: str):
return await conn.fetchrow(
"""
SELECT id, name, slug
FROM facilities
WHERE slug = $1
AND COALESCE(is_published, TRUE) = TRUE
LIMIT 1
""",
slug,
)
async def build_facility_rating_payload(
conn,
facility_id: int,
viewer_id: int | None = None,
) -> dict[str, Any]:
summary_row = await conn.fetchrow(
"""
SELECT
COUNT(*)::int AS rating_count,
ROUND(AVG(quality_rating)::numeric, 1) AS quality_average,
ROUND(AVG(conditions_rating)::numeric, 1) AS conditions_average,
ROUND(AVG(hospitality_rating)::numeric, 1) AS hospitality_average,
ROUND(AVG((quality_rating + conditions_rating + hospitality_rating)::numeric / 3), 1) AS overall_average
FROM facility_ratings
WHERE facility_id = $1
""",
facility_id,
)
user_rating = None
if viewer_id is not None:
user_row = await conn.fetchrow(
"""
SELECT
quality_rating,
conditions_rating,
hospitality_rating,
created_at,
updated_at
FROM facility_ratings
WHERE facility_id = $1 AND user_id = $2
LIMIT 1
""",
facility_id,
viewer_id,
)
if user_row:
user_rating = {
"quality_rating": int(user_row["quality_rating"]),
"conditions_rating": int(user_row["conditions_rating"]),
"hospitality_rating": int(user_row["hospitality_rating"]),
"overall_rating": round(
(
int(user_row["quality_rating"])
+ int(user_row["conditions_rating"])
+ int(user_row["hospitality_rating"])
)
/ 3,
1,
),
"created_at": user_row["created_at"].isoformat() if user_row["created_at"] else None,
"updated_at": user_row["updated_at"].isoformat() if user_row["updated_at"] else None,
}
rating_count = int(summary_row["rating_count"] or 0) if summary_row else 0
return {
"summary": {
"rating_count": rating_count,
"quality_average": float(summary_row["quality_average"]) if summary_row and summary_row["quality_average"] is not None else None,
"conditions_average": float(summary_row["conditions_average"]) if summary_row and summary_row["conditions_average"] is not None else None,
"hospitality_average": float(summary_row["hospitality_average"]) if summary_row and summary_row["hospitality_average"] is not None else None,
"overall_average": float(summary_row["overall_average"]) if summary_row and summary_row["overall_average"] is not None else None,
},
"user_rating": user_rating,
}
async def find_published_article_by_slug(conn, slug: str, section: str | None = None):
if section:
return await conn.fetchrow(
"""
SELECT *
FROM articles
WHERE slug = $1 AND status = 'published' AND section = $2
""",
slug,
section,
)
return await conn.fetchrow(
"""
SELECT *
FROM articles
WHERE slug = $1 AND status = 'published'
""",
slug,
)
2026-04-18 09:00:16 +02:00
async def fetch_facility_slugs(conn, facility_ids: list[int]) -> list[str]:
unique_ids = sorted({int(facility_id) for facility_id in facility_ids if facility_id})
if not unique_ids:
return []
rows = await conn.fetch(
"SELECT slug FROM facilities WHERE id = ANY($1::int[])",
unique_ids,
)
return [
str(row["slug"]).strip()
for row in rows
if row.get("slug") and str(row["slug"]).strip()
]
2026-04-13 15:29:43 +02:00
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
2026-04-21 07:21:46 +02:00
ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT TRUE,
2026-04-15 08:15:53 +02:00
ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ,
2026-04-19 13:35:48 +02:00
ADD COLUMN IF NOT EXISTS golfamore_url TEXT,
2026-04-15 08:15:53 +02:00
ADD COLUMN IF NOT EXISTS golfpakker_url TEXT,
ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB,
ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS vtg_content_draft JSONB,
ADD COLUMN IF NOT EXISTS vtg_courses_draft JSONB,
ADD COLUMN IF NOT EXISTS vtg_content_updated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS vtg_courses_updated_at TIMESTAMPTZ
""")
async def ensure_vtg_course_tables(conn):
await conn.execute("""
CREATE TABLE IF NOT EXISTS facility_vtg_courses (
id SERIAL PRIMARY KEY,
facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
display_label TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Ledig',
start_date DATE,
end_date DATE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_facility_vtg_courses_facility_id
ON facility_vtg_courses (facility_id)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_facility_vtg_courses_start_date
ON facility_vtg_courses (start_date)
""")
facility_columns = await get_table_columns(conn, "facilities")
if "vtg_datoer" in facility_columns:
rows = await conn.fetch(
"""
SELECT f.id, f.vtg_datoer
FROM facilities f
WHERE COALESCE(jsonb_array_length(CASE WHEN jsonb_typeof(f.vtg_datoer) = 'array' THEN f.vtg_datoer ELSE '[]'::jsonb END), 0) > 0
AND NOT EXISTS (
SELECT 1
FROM facility_vtg_courses c
WHERE c.facility_id = f.id
)
"""
)
for row in rows:
await replace_facility_vtg_courses(conn, int(row["id"]), row["vtg_datoer"])
if "vtg_content_draft" in facility_columns and "vtg_courses_draft" in facility_columns and "vtg_draft" in facility_columns:
draft_rows = await conn.fetch(
"""
SELECT id, vtg_draft, vtg_content_draft, vtg_courses_draft
FROM facilities
WHERE vtg_draft IS NOT NULL
AND vtg_draft::text != '{}'
"""
)
for row in draft_rows:
legacy_draft = row["vtg_draft"]
content_draft = row["vtg_content_draft"]
courses_draft = row["vtg_courses_draft"]
if isinstance(legacy_draft, str):
try:
legacy_draft = json.loads(legacy_draft)
except json.JSONDecodeError:
legacy_draft = {}
if isinstance(content_draft, str):
try:
content_draft = json.loads(content_draft)
except json.JSONDecodeError:
content_draft = None
if isinstance(courses_draft, str):
try:
courses_draft = json.loads(courses_draft)
except json.JSONDecodeError:
courses_draft = None
next_content_draft = (
content_draft
if isinstance(content_draft, dict) and content_draft
else prepare_vtg_content_draft_payload(legacy_draft)
)
next_courses_draft = (
courses_draft
if isinstance(courses_draft, list)
else prepare_vtg_course_draft_payload(legacy_draft)
)
if next_content_draft != content_draft or next_courses_draft != courses_draft:
await conn.execute(
"""
UPDATE facilities
SET vtg_content_draft = $1::jsonb,
vtg_courses_draft = $2::jsonb
WHERE id = $3
""",
json.dumps(next_content_draft),
json.dumps(next_courses_draft if next_courses_draft is not None else []),
int(row["id"]),
)
2026-04-24 09:17:14 +02:00
async def ensure_place_pages_table(conn):
await conn.execute("""
CREATE TABLE IF NOT EXISTS place_pages (
slug VARCHAR(255) PRIMARY KEY,
factbox_intro_html TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
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,
section VARCHAR(32) NOT NULL DEFAULT 'banebesok',
2026-04-13 15:29:43 +02:00
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,
media_gallery JSONB NOT NULL DEFAULT '[]'::jsonb,
featured_media_id VARCHAR(255),
2026-04-13 15:29:43 +02:00
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("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS section VARCHAR(32) NOT NULL DEFAULT 'banebesok'
""")
await conn.execute("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS media_gallery JSONB NOT NULL DEFAULT '[]'::jsonb
""")
await conn.execute("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS featured_media_id VARCHAR(255)
""")
await conn.execute("""
UPDATE articles
SET section = 'banebesok'
WHERE section IS NULL OR TRIM(section) = ''
""")
2026-04-13 15:29:43 +02:00
await conn.execute("CREATE INDEX IF NOT EXISTS articles_status_idx ON articles (status)")
await conn.execute("CREATE INDEX IF NOT EXISTS articles_section_idx ON articles (section)")
2026-04-13 15:29:43 +02:00
await conn.execute("CREATE INDEX IF NOT EXISTS articles_published_at_idx ON articles (published_at DESC)")
async def ensure_public_user_tables(conn):
await conn.execute("""
CREATE TABLE IF NOT EXISTS public_users (
id SERIAL PRIMARY KEY,
vipps_sub VARCHAR(255) UNIQUE,
google_sub VARCHAR(255) UNIQUE,
full_name VARCHAR(255),
given_name VARCHAR(255),
family_name VARCHAR(255),
email VARCHAR(255),
phone_number VARCHAR(64),
display_name VARCHAR(255),
is_blocked BOOLEAN NOT NULL DEFAULT FALSE,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
)
""")
await conn.execute("""
ALTER TABLE public_users
ALTER COLUMN vipps_sub DROP NOT NULL
""")
await conn.execute("""
ALTER TABLE public_users
ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255)
""")
await conn.execute("""
ALTER TABLE public_users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ
""")
await conn.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS public_users_google_sub_idx
ON public_users (google_sub)
WHERE google_sub IS NOT NULL
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS public_users_email_lower_idx
ON public_users (LOWER(email))
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS article_comments (
id SERIAL PRIMARY KEY,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES public_users(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES article_comments(id) ON DELETE SET NULL,
body TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
ip_hash VARCHAR(128),
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ
)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS article_comments_article_idx
ON article_comments (article_id, created_at ASC)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS article_comments_user_idx
ON article_comments (user_id, created_at DESC)
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS public_magic_links (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES public_users(id) ON DELETE CASCADE,
token_hash VARCHAR(128) NOT NULL UNIQUE,
requested_ip_hash VARCHAR(128),
user_agent TEXT,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS public_magic_links_user_idx
ON public_magic_links (user_id, created_at DESC)
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS facility_ratings (
id SERIAL PRIMARY KEY,
facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES public_users(id) ON DELETE CASCADE,
quality_rating SMALLINT NOT NULL,
conditions_rating SMALLINT NOT NULL,
hospitality_rating SMALLINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (facility_id, user_id),
CHECK (quality_rating BETWEEN 1 AND 5),
CHECK (conditions_rating BETWEEN 1 AND 5),
CHECK (hospitality_rating BETWEEN 1 AND 5)
)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS facility_ratings_facility_idx
ON facility_ratings (facility_id, updated_at DESC)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS facility_ratings_user_idx
ON facility_ratings (user_id, updated_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:
print("📡 Forsøker å koble til database")
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)
await ensure_vtg_course_tables(conn)
2026-04-24 09:17:14 +02:00
await ensure_place_pages_table(conn)
2026-04-13 15:29:43 +02:00
await ensure_articles_table(conn)
await ensure_public_user_tables(conn)
2026-04-10 18:37:33 +02:00
await ensure_scrape_jobs_table(conn)
2026-04-26 09:52:05 +02:00
await ensure_course_status_history_table(conn)
2026-04-19 10:24:33 +02:00
await ensure_weather_forecast_table(conn)
app.state.weather_sync_stop_event = asyncio.Event()
app.state.weather_sync_task = asyncio.create_task(
weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event)
)
app.state.contact_submission_tracker = {}
2026-04-26 11:29:35 +02:00
initialize_public_api_caches()
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-04-19 10:24:33 +02:00
weather_stop_event = getattr(app.state, "weather_sync_stop_event", None)
weather_sync_task = getattr(app.state, "weather_sync_task", None)
if weather_stop_event is not None:
weather_stop_event.set()
if weather_sync_task is not None:
await weather_sync_task
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-04-17 09:25:32 +02:00
remember_me = bool(data.get("remember_me") or data.get("rememberMe"))
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:
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
2026-03-02 09:56:37 +01:00
try:
is_valid = pwd_context.verify(data.get('password'), admin['password_hash'])
2026-03-02 09:56:37 +01:00
except Exception as e:
print("❌ Kunne ikke verifisere admin-passord")
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:
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
2026-03-02 09:56:37 +01:00
temp_token = jwt.encode(
2026-04-17 09:25:32 +02:00
{
"sub": admin['username'],
"partial": True,
"remember_me": remember_me,
"exp": datetime.utcnow() + timedelta(minutes=5),
},
SECRET_KEY, algorithm=ALGORITHM
)
return {"step": "2fa", "temp_token": temp_token}
@app.post("/api/auth/verify-2fa")
2026-04-17 09:25:32 +02:00
async def verify_2fa(data: dict, response: Response, request: Request):
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-04-17 09:25:32 +02:00
remember_me = bool(payload.get("remember_me"))
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')):
print("❌ Ugyldig 2FA-kode ved admin-innlogging")
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
2026-04-17 09:25:32 +02:00
session_max_age = (
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS
if remember_me
else ADMIN_SESSION_MAX_AGE_SECONDS
)
final_token = jwt.encode(
2026-04-17 09:25:32 +02:00
{"sub": username, "exp": datetime.utcnow() + timedelta(seconds=session_max_age)},
SECRET_KEY, algorithm=ALGORITHM
)
2026-04-17 09:25:32 +02:00
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,
2026-04-17 09:25:32 +02:00
max_age=session_max_age,
expires=session_max_age,
2026-03-02 09:56:37 +01:00
httponly=True,
samesite="lax",
2026-04-17 09:25:32 +02:00
secure=should_use_secure_cookies(request),
)
return {"status": "success"}
2026-04-11 09:54:54 +02:00
@app.post("/api/auth/logout")
2026-04-17 09:25:32 +02:00
async def logout(response: Response, request: Request):
2026-04-11 09:54:54 +02:00
"""Logger ut admin ved å slette sesjonscookien."""
response.delete_cookie(
key="admin_session",
httponly=True,
samesite="lax",
2026-04-17 09:25:32 +02:00
secure=should_use_secure_cookies(request),
2026-04-11 09:54:54 +02:00
)
return {"status": "success"}
@app.get("/api/public/me")
async def get_public_me(request: Request):
user = await get_authenticated_public_user(request)
return {
"auth_configured": get_public_auth_config()["configured"],
"auth_providers": get_public_auth_config(),
"user": user,
}
@app.get("/api/public/auth/google/start")
async def google_login_start(request: Request, return_to: Optional[str] = Query(default="/")):
if not is_google_login_configured():
raise HTTPException(status_code=503, detail="Google-login er ikke konfigurert ennå.")
discovery = await get_google_discovery_document()
authorization_endpoint = str(discovery.get("authorization_endpoint") or "").strip()
if not authorization_endpoint:
raise HTTPException(status_code=500, detail="Fant ikke Google authorization endpoint.")
state = secrets.token_urlsafe(24)
redirect_uri = build_google_redirect_uri(request)
sanitized_return_to = normalize_return_to_path(return_to)
secure_cookies = should_use_secure_cookies(request)
query = urlencode(
{
"client_id": GOOGLE_CLIENT_ID,
"response_type": "code",
"scope": GOOGLE_OAUTH_SCOPES,
"state": state,
"redirect_uri": redirect_uri,
"prompt": "select_account",
}
)
response = Response(status_code=302)
response.headers["Location"] = f"{authorization_endpoint}?{query}"
response.set_cookie(
key="google_login_state",
value=state,
httponly=True,
samesite="lax",
secure=secure_cookies,
max_age=600,
)
response.set_cookie(
key="google_login_return_to",
value=sanitized_return_to,
httponly=True,
samesite="lax",
secure=secure_cookies,
max_age=600,
)
return response
@app.get("/api/public/auth/google/callback")
async def google_login_callback(
request: Request,
code: Optional[str] = Query(default=None),
state: Optional[str] = Query(default=None),
error: Optional[str] = Query(default=None),
):
return_to = normalize_return_to_path(request.cookies.get("google_login_return_to"))
cookie_state = request.cookies.get("google_login_state")
secure_cookies = should_use_secure_cookies(request)
redirect_response = Response(status_code=302)
redirect_response.delete_cookie("google_login_state", httponly=True, samesite="lax", secure=secure_cookies)
redirect_response.delete_cookie("google_login_return_to", httponly=True, samesite="lax", secure=secure_cookies)
if error:
redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_cancelled")
return redirect_response
if not code or not state or not cookie_state or state != cookie_state:
redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_invalid_state")
return redirect_response
if not is_google_login_configured():
redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_not_configured")
return redirect_response
discovery = await get_google_discovery_document()
token_endpoint = str(discovery.get("token_endpoint") or "").strip()
userinfo_endpoint = str(discovery.get("userinfo_endpoint") or "").strip()
if not token_endpoint or not userinfo_endpoint:
raise HTTPException(status_code=500, detail="Google discovery mangler token/userinfo endpoint.")
redirect_uri = build_google_redirect_uri(request)
async with httpx.AsyncClient(timeout=20.0) as client:
token_response = await client.post(
token_endpoint,
auth=httpx.BasicAuth(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET),
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
headers={
"Content-Type": "application/x-www-form-urlencoded",
},
)
if token_response.status_code >= 400:
redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_token_error")
return redirect_response
token_payload = token_response.json()
access_token = str(token_payload.get("access_token") or "").strip()
if not access_token:
redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_token_error")
return redirect_response
userinfo_response = await client.get(
userinfo_endpoint,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
)
if userinfo_response.status_code >= 400:
redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_userinfo_error")
return redirect_response
profile = userinfo_response.json()
async with app.state.pool.acquire() as conn:
user_row = await upsert_public_user_from_google_profile(conn, profile)
user = format_public_user_row(user_row)
if user and user["is_blocked"]:
redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "blocked")
return redirect_response
redirect_response.set_cookie(
key=PUBLIC_SESSION_COOKIE,
value=create_public_session_token(int(user["id"])),
httponly=True,
samesite="lax",
secure=secure_cookies,
max_age=PUBLIC_SESSION_MAX_AGE_SECONDS,
)
redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_success")
return redirect_response
@app.post("/api/public/auth/magic-link/request")
async def request_magic_link(request: Request, payload: PublicMagicLinkRequest):
if not is_magic_link_configured():
raise HTTPException(status_code=503, detail="Magic link er ikke konfigurert ennå.")
email = normalize_public_email(payload.email)
if not email or "@" not in email:
raise HTTPException(status_code=400, detail="Oppgi en gyldig e-postadresse.")
return_to = normalize_return_to_path(payload.return_to)
token = secrets.token_urlsafe(32)
token_hash = hash_magic_link_token(token)
expires_at = datetime.utcnow() + timedelta(minutes=PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES)
async with app.state.pool.acquire() as conn:
user_row = await get_or_create_public_user_by_email(conn, email)
user = format_public_user_row(user_row)
if user and user["is_blocked"]:
return {
"status": "success",
"detail": "Hvis adressen kan brukes til innlogging, sender vi deg en lenke nå.",
}
recent_threshold = datetime.utcnow() - timedelta(seconds=PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS)
recent = await conn.fetchrow(
"""
SELECT id
FROM public_magic_links
WHERE user_id = $1 AND created_at >= $2
ORDER BY created_at DESC
LIMIT 1
""",
int(user_row["id"]),
recent_threshold,
)
if recent:
return {
"status": "success",
"detail": "Hvis adressen kan brukes til innlogging, sender vi deg en lenke nå.",
}
await conn.execute(
"""
INSERT INTO public_magic_links (
user_id, token_hash, requested_ip_hash, user_agent, expires_at
) VALUES (
$1, $2, $3, $4, $5
)
""",
int(user_row["id"]),
token_hash,
hash_request_ip(request),
request.headers.get("user-agent"),
expires_at,
)
login_url = (
f"{build_public_base_url(request)}/api/public/auth/magic-link/verify?"
f"{urlencode({'token': token, 'return_to': return_to})}"
)
await send_magic_link_email(email, login_url)
return {
"status": "success",
"detail": "Hvis adressen kan brukes til innlogging, sender vi deg en lenke nå.",
}
@app.post("/api/public/contact")
async def submit_public_contact_form(request: Request, payload: PublicContactFormRequest):
if not is_contact_form_configured():
raise HTTPException(status_code=503, detail="Kontaktskjema er ikke konfigurert ennå.")
if str(payload.website or "").strip():
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
name = str(payload.name or "").strip()
email = normalize_public_email(payload.email)
topic = str(payload.topic or "").strip()
message = str(payload.message or "").strip()
if len(name) < 2 or len(name) > 120:
raise HTTPException(status_code=400, detail="Oppgi et gyldig navn.")
if not email or "@" not in email or len(email) > 255:
raise HTTPException(status_code=400, detail="Oppgi en gyldig e-postadresse.")
if len(topic) < 2 or len(topic) > 140:
raise HTTPException(status_code=400, detail="Oppgi et gyldig emne.")
if len(message) < 20 or len(message) > 5000:
raise HTTPException(status_code=400, detail="Meldingen må være mellom 20 og 5000 tegn.")
now_ts = int(datetime.utcnow().timestamp())
if payload.started_at and now_ts - int(payload.started_at) < CONTACT_FORM_MIN_FILL_SECONDS:
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
ip_hash = hash_request_ip(request)
tracker: dict[str, list[int]] = getattr(app.state, "contact_submission_tracker", {})
cutoff = now_ts - CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS
for key in list(tracker.keys()):
recent = [ts for ts in tracker.get(key, []) if ts >= cutoff]
if recent:
tracker[key] = recent
else:
tracker.pop(key, None)
rate_keys = [f"email:{email}"]
if ip_hash:
rate_keys.append(f"ip:{ip_hash}")
for key in rate_keys:
attempts = tracker.get(key, [])
if len(attempts) >= CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS:
raise HTTPException(
status_code=429,
detail="For mange meldinger på kort tid. Prøv igjen senere.",
)
await send_contact_form_email(
sender_name=name,
sender_email=email,
topic=topic,
message=message,
ip_hash=ip_hash,
)
for key in rate_keys:
tracker.setdefault(key, []).append(now_ts)
app.state.contact_submission_tracker = tracker
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
2026-04-19 13:35:48 +02:00
@app.post("/api/public/facility-feedback")
async def submit_public_facility_feedback(request: Request, payload: PublicFacilityFeedbackRequest):
if not is_contact_form_configured():
raise HTTPException(status_code=503, detail="Skjemaet er ikke konfigurert ennå.")
if str(payload.website or "").strip():
return {
"status": "success",
"detail": "Takk for meldingen. Vi ser på innspillet så snart vi kan.",
}
name = str(payload.name or "").strip()
email = normalize_public_email(payload.email)
message = str(payload.message or "").strip()
if len(name) < 2 or len(name) > 120:
raise HTTPException(status_code=400, detail="Oppgi et gyldig navn.")
if not email or "@" not in email or len(email) > 255:
raise HTTPException(status_code=400, detail="Oppgi en gyldig e-postadresse.")
if len(message) < 20 or len(message) > 5000:
raise HTTPException(status_code=400, detail="Meldingen må være mellom 20 og 5000 tegn.")
now_ts = int(datetime.utcnow().timestamp())
if payload.started_at and now_ts - int(payload.started_at) < CONTACT_FORM_MIN_FILL_SECONDS:
return {
"status": "success",
"detail": "Takk for meldingen. Vi ser på innspillet så snart vi kan.",
}
async with app.state.pool.acquire() as conn:
facility = await conn.fetchrow(
"SELECT id, name, slug FROM facilities WHERE id = $1",
payload.facility_id,
)
if not facility:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.")
ip_hash = hash_request_ip(request)
tracker: dict[str, list[int]] = getattr(app.state, "contact_submission_tracker", {})
cutoff = now_ts - CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS
for key in list(tracker.keys()):
recent = [ts for ts in tracker.get(key, []) if ts >= cutoff]
if recent:
tracker[key] = recent
else:
tracker.pop(key, None)
rate_keys = [f"email:{email}"]
if ip_hash:
rate_keys.append(f"ip:{ip_hash}")
for key in rate_keys:
attempts = tracker.get(key, [])
if len(attempts) >= CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS:
raise HTTPException(
status_code=429,
detail="For mange meldinger på kort tid. Prøv igjen senere.",
)
await send_facility_feedback_email(
facility_name=str(facility["name"] or "").strip() or "ukjent golfanlegg",
facility_slug=str(facility["slug"] or "").strip(),
sender_name=name,
sender_email=email,
message=message,
ip_hash=ip_hash,
)
for key in rate_keys:
tracker.setdefault(key, []).append(now_ts)
app.state.contact_submission_tracker = tracker
return {
"status": "success",
"detail": "Takk for meldingen. Vi ser på innspillet så snart vi kan.",
}
@app.get("/api/public/auth/magic-link/verify")
async def verify_magic_link(
request: Request,
token: Optional[str] = Query(default=None),
return_to: Optional[str] = Query(default="/"),
):
secure_cookies = should_use_secure_cookies(request)
redirect_target = normalize_return_to_path(return_to)
if not token:
response = Response(status_code=302)
response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_invalid")
return response
token_hash = hash_magic_link_token(token)
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
ml.id AS magic_link_id,
ml.user_id AS magic_link_user_id,
ml.expires_at AS magic_link_expires_at,
ml.consumed_at AS magic_link_consumed_at,
u.*
FROM public_magic_links ml
JOIN public_users u ON u.id = ml.user_id
WHERE ml.token_hash = $1
LIMIT 1
""",
token_hash,
)
response = Response(status_code=302)
if not row:
response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_invalid")
return response
if row["magic_link_consumed_at"] is not None:
response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_invalid")
return response
expires_at = row["magic_link_expires_at"]
now = datetime.now(expires_at.tzinfo) if getattr(expires_at, "tzinfo", None) else datetime.utcnow()
if expires_at <= now:
response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_expired")
return response
user = format_public_user_row(row)
if user and user["is_blocked"]:
response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "blocked")
return response
await conn.execute(
"""
UPDATE public_magic_links
SET consumed_at = NOW()
WHERE id = $1
""",
row["magic_link_id"],
)
await conn.execute(
"""
UPDATE public_users
SET
email_verified_at = COALESCE(email_verified_at, NOW()),
updated_at = NOW(),
last_login_at = NOW()
WHERE id = $1
""",
row["magic_link_user_id"],
)
response.set_cookie(
key=PUBLIC_SESSION_COOKIE,
value=create_public_session_token(int(row["magic_link_user_id"])),
httponly=True,
samesite="lax",
secure=secure_cookies,
max_age=PUBLIC_SESSION_MAX_AGE_SECONDS,
)
response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_success")
return response
@app.get("/api/public/auth/providers")
async def get_public_auth_providers():
return get_public_auth_config()
@app.post("/api/public/auth/logout")
async def public_logout(request: Request, response: Response):
response.delete_cookie(
key=PUBLIC_SESSION_COOKIE,
httponly=True,
samesite="lax",
secure=should_use_secure_cookies(request),
)
return {"status": "success"}
@app.get("/api/facilities/{slug}/ratings")
async def get_facility_ratings(request: Request, slug: str):
viewer = await get_authenticated_public_user(request)
async with app.state.pool.acquire() as conn:
facility = await get_published_facility_by_slug(conn, slug)
if not facility:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.")
payload = await build_facility_rating_payload(
conn,
int(facility["id"]),
int(viewer["id"]) if viewer else None,
)
return {
"auth_configured": get_public_auth_config()["configured"],
"auth_providers": get_public_auth_config(),
"viewer": viewer,
**payload,
}
@app.put("/api/facilities/{slug}/ratings")
async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsertRequest, slug: str):
viewer = await require_authenticated_public_user(request)
async with app.state.pool.acquire() as conn:
facility = await get_published_facility_by_slug(conn, slug)
if not facility:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.")
await conn.execute(
"""
INSERT INTO facility_ratings (
facility_id, user_id, quality_rating, conditions_rating, hospitality_rating
) VALUES (
$1, $2, $3, $4, $5
)
ON CONFLICT (facility_id, user_id)
DO UPDATE SET
quality_rating = EXCLUDED.quality_rating,
conditions_rating = EXCLUDED.conditions_rating,
hospitality_rating = EXCLUDED.hospitality_rating,
updated_at = NOW()
""",
int(facility["id"]),
int(viewer["id"]),
payload.quality_rating,
payload.conditions_rating,
payload.hospitality_rating,
)
response_payload = await build_facility_rating_payload(
conn,
int(facility["id"]),
int(viewer["id"]),
)
return {
"detail": "Vurderingen er lagret.",
"viewer": viewer,
**response_payload,
}
# --- DATA ENDPOINTS ---
2026-04-26 11:29:35 +02:00
def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | None]:
normalized_view = (view or "").strip().lower()
course_statuses_sql = """
(
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
"""
weather_compact_sql = """
(
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
SELECT
day_offset,
dry_daylight
FROM facility_weather_forecast
WHERE facility_id = f.id
ORDER BY day_offset ASC
) w_data
) as weather_forecast
"""
weather_full_sql = """
(
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
SELECT
forecast_date,
day_offset,
dry_all_day,
dry_daylight,
precip_mm,
precip_probability_max,
daylight_precip_mm,
daylight_precip_probability_max,
confidence,
source_updated_at,
source_expires_at,
calculated_at
FROM facility_weather_forecast
WHERE facility_id = f.id
ORDER BY day_offset ASC
) w_data
) as weather_forecast
"""
has_golfpakker_sql = """
CASE
WHEN jsonb_typeof(f.golfpakker) = 'array' AND jsonb_array_length(f.golfpakker) > 0 THEN TRUE
WHEN NULLIF(BTRIM(COALESCE(f.golfpakker_url, '')), '') IS NOT NULL THEN TRUE
ELSE FALSE
END as has_golfpakker
"""
total_hole_count_sql = """
(
SELECT COUNT(*)
FROM holes h
JOIN courses c ON c.id = h.course_id
WHERE c.facility_id = f.id
) as total_hole_count
"""
hole_par_counts_sql = """
(
SELECT jsonb_build_object(
'3', COUNT(*) FILTER (WHERE h.par = 3),
'4', COUNT(*) FILTER (WHERE h.par = 4),
'5', COUNT(*) FILTER (WHERE h.par = 5),
'6', COUNT(*) FILTER (WHERE h.par = 6)
)
FROM holes h
JOIN courses c ON c.id = h.course_id
WHERE c.facility_id = f.id
) as hole_par_counts
"""
shortest_hole_sql = """
(
SELECT MIN((length_value.value)::int)
FROM holes h
JOIN courses c ON c.id = h.course_id
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
WHERE c.facility_id = f.id
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
AND length_value.value ~ '^[0-9]+$'
AND (length_value.value)::int BETWEEN 30 AND 900
) as shortest_hole_meters
"""
longest_hole_sql = """
(
SELECT MAX((length_value.value)::int)
FROM holes h
JOIN courses c ON c.id = h.course_id
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
WHERE c.facility_id = f.id
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
AND length_value.value ~ '^[0-9]+$'
AND (length_value.value)::int BETWEEN 30 AND 900
) as longest_hole_meters
"""
if normalized_view in {"search", "home"}:
return (
f"""
SELECT
f.id,
f.slug,
f.name,
f.architect,
f.description,
f.city,
f.county,
f.banetype,
f.image_url,
f.phone,
f.website_url,
f.golfbox_booking_url,
f.golfbox_tournament_url,
f.weather_url,
f.lat,
f.lng,
f.golfamore,
f.golfamore_url,
f.nsg_url,
{has_golfpakker_sql},
f.vtg_pris,
f.vtg_lenke,
f.vtg_beskrivelse,
f.amenities,
f.golfamore_data,
f.nsg_data,
f.vtg_datoer,
f.footnote,
f.footnote_updated_at,
f.status_updated_at,
{course_statuses_sql},
{weather_compact_sql}
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
""",
FACILITY_VIEW_SEARCH_FIELDS,
)
if normalized_view == "place":
return (
f"""
2026-04-22 06:48:15 +02:00
SELECT
f.id,
f.slug,
f.name,
f.architect,
f.description,
f.city,
f.county,
f.banetype,
f.image_url,
f.phone,
f.website_url,
f.golfbox_booking_url,
f.golfbox_tournament_url,
f.weather_url,
f.lat,
f.lng,
f.golfamore,
f.golfamore_url,
f.nsg_url,
2026-04-26 11:29:35 +02:00
{has_golfpakker_sql},
2026-04-22 06:48:15 +02:00
f.greenfee,
f.standard_medlemskap,
f.vtg_pris,
f.vtg_lenke,
f.vtg_beskrivelse,
f.amenities,
f.golfamore_data,
f.nsg_data,
f.vtg_datoer,
f.footnote,
f.footnote_updated_at,
f.status_updated_at,
2026-04-26 11:29:35 +02:00
{course_statuses_sql},
{total_hole_count_sql},
{hole_par_counts_sql},
{shortest_hole_sql},
{longest_hole_sql},
{weather_compact_sql}
2026-04-22 06:48:15 +02:00
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
2026-04-26 11:29:35 +02:00
""",
FACILITY_VIEW_PLACE_FIELDS,
)
if normalized_view == "membership":
return (
"""
SELECT
f.id,
f.slug,
f.name,
f.city,
f.county,
f.medlemskap_url,
f.membership_updated_at,
f.standard_medlemskap_kommentarer,
f.navn_standard_medlemskap,
f.standard_medlemskap,
f.navn_rimeligste_alternativ,
f.rimeligste_alternativ
2026-04-21 07:21:46 +02:00
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
2026-02-27 09:35:30 +01:00
ORDER BY f.name ASC
2026-04-26 11:29:35 +02:00
""",
FACILITY_VIEW_MEMBERSHIP_FIELDS,
)
if normalized_view == "vtg":
return (
"""
SELECT
f.id,
f.slug,
f.name,
f.city,
f.county,
f.lat,
f.lng,
f.status_updated_at,
f.vtg_pris,
f.vtg_lenke,
f.vtg_beskrivelse,
f.vtg_datoer,
f.vtg_updated_at
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
""",
FACILITY_VIEW_VTG_FIELDS,
)
if normalized_view == "clubnumbers":
return (
"""
SELECT
f.id,
f.slug,
f.name,
f.city,
f.county,
f.ngf_number
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
""",
FACILITY_VIEW_CLUBNUMBERS_FIELDS,
)
if normalized_view == "sitemap":
return (
"""
SELECT
f.slug,
f.status_updated_at,
f.vtg_updated_at
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
""",
FACILITY_VIEW_SITEMAP_FIELDS,
)
if normalized_view == "aliases":
return (
"""
SELECT
f.slug,
f.name
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
""",
FACILITY_VIEW_ALIASES_FIELDS,
)
return (
f"""
SELECT
f.*,
{course_statuses_sql},
{total_hole_count_sql},
{hole_par_counts_sql},
{shortest_hole_sql},
{longest_hole_sql},
{weather_full_sql}
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
""",
None,
)
@app.get("/api/facilities")
async def get_facilities(
response: Response,
summary: bool = False,
view: str | None = Query(default=None),
):
"""Henter publiserte golfanlegg i profiler tilpasset offentlig bruk."""
resolved_view = "place" if summary and not view else view
cache_key = ((resolved_view or "").strip().lower() or "__default__")
cache_ttl = get_public_facilities_cache_ttl(resolved_view)
facilities_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_facilities_cache", {})
cached_payload = read_public_cache_entry(facilities_cache, cache_key)
if cached_payload is not None:
apply_public_cache_headers(response, cache_ttl)
return cached_payload
query, fields = build_public_facilities_query(resolved_view)
async with app.state.pool.acquire() as conn:
rows = await conn.fetch(query)
payload = [sanitize_public_facility_row(row, fields=fields) for row in rows]
write_public_cache_entry(facilities_cache, cache_key, payload, cache_ttl)
apply_public_cache_headers(response, cache_ttl)
return payload
2026-02-26 09:20:51 +01:00
@app.get("/api/facilities/{slug}")
2026-04-26 11:29:35 +02:00
async def get_facility(slug: str, response: Response):
2026-03-02 09:56:37 +01:00
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
2026-04-26 11:29:35 +02:00
normalized_slug = str(slug or "").strip().lower()
detail_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_facility_detail_cache", {})
cached_payload = read_public_cache_entry(detail_cache, normalized_slug)
if cached_payload is not None:
apply_public_cache_headers(response, PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS)
return cached_payload
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
2026-04-19 10:24:33 +02:00
) as courses, (
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
SELECT
forecast_date,
day_offset,
dry_all_day,
dry_daylight,
precip_mm,
precip_probability_max,
daylight_precip_mm,
daylight_precip_probability_max,
confidence,
source_updated_at,
source_expires_at,
calculated_at
FROM facility_weather_forecast
WHERE facility_id = f.id
ORDER BY day_offset ASC
) w_data
) as weather_forecast
2026-04-21 07:21:46 +02:00
FROM facilities f
WHERE f.slug = $1
AND COALESCE(f.is_published, TRUE) = TRUE
2026-02-26 09:20:51 +01:00
""", 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-04-26 11:29:35 +02:00
payload = sanitize_public_facility_row(row)
write_public_cache_entry(
detail_cache,
normalized_slug,
payload,
PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS,
)
apply_public_cache_headers(response, PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS)
return payload
2026-02-27 09:35:30 +01:00
2026-04-13 15:29:43 +02:00
2026-04-24 09:17:14 +02:00
@app.get("/api/place-pages/{slug}")
2026-04-26 11:29:35 +02:00
async def get_place_page(slug: str, response: Response):
2026-04-24 09:17:14 +02:00
normalized_slug = str(slug or "").strip().lower()
if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler.")
2026-04-26 11:29:35 +02:00
place_page_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_place_page_cache", {})
cached_payload = read_public_cache_entry(place_page_cache, normalized_slug)
if cached_payload is not None:
apply_public_cache_headers(response, PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS)
return cached_payload
2026-04-24 09:17:14 +02:00
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM place_pages WHERE slug = $1",
normalized_slug,
)
if not row:
return {
"slug": normalized_slug,
"factbox_intro_html": "",
"created_at": None,
"updated_at": None,
}
2026-04-26 11:29:35 +02:00
payload = format_place_page_row(row)
write_public_cache_entry(
place_page_cache,
normalized_slug,
payload,
PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS,
)
apply_public_cache_headers(response, PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS)
return payload
2026-04-24 09:17:14 +02:00
2026-04-21 07:21:46 +02:00
@app.get("/api/admin/facilities")
async def get_admin_facilities():
"""Henter alle golfanlegg for admin, også upubliserte."""
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, (
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
SELECT
forecast_date,
day_offset,
dry_all_day,
dry_daylight,
precip_mm,
precip_probability_max,
daylight_precip_mm,
daylight_precip_probability_max,
confidence,
source_updated_at,
source_expires_at,
calculated_at
FROM facility_weather_forecast
WHERE facility_id = f.id
ORDER BY day_offset ASC
) w_data
) as weather_forecast
FROM facilities f
ORDER BY f.name ASC
""")
return [format_row(row) for row in rows]
@app.get("/api/admin/facilities/{slug}")
async def get_admin_facility(slug: str):
"""Henter full anleggsdetalj for admin, også når anlegget er upublisert."""
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
ORDER BY c.is_main_course DESC, c.id ASC
) c_data
) as courses, (
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
SELECT
forecast_date,
day_offset,
dry_all_day,
dry_daylight,
precip_mm,
precip_probability_max,
daylight_precip_mm,
daylight_precip_probability_max,
confidence,
source_updated_at,
source_expires_at,
calculated_at
FROM facility_weather_forecast
WHERE facility_id = f.id
ORDER BY day_offset ASC
) w_data
) as weather_forecast
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)
2026-04-26 09:52:05 +02:00
@app.post("/api/admin/facilities")
async def create_admin_facility(request: Request):
"""Oppretter et nytt golfanlegg og lagrer full editor-payload i samme operasjon."""
data = apply_legacy_facility_field_aliases(await request.json())
facility_name = str(data.get("name") or "").strip()
if not facility_name:
raise HTTPException(status_code=400, detail="Anleggsnavn mangler.")
normalized_slug = normalize_facility_slug(data.get("slug") or facility_name)
if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler eller er ugyldig.")
async with app.state.pool.acquire() as conn:
async with conn.transaction():
existing_id = await conn.fetchval(
"SELECT id FROM facilities WHERE slug = $1",
normalized_slug,
)
if existing_id:
raise HTTPException(status_code=409, detail="Slug er allerede i bruk.")
facility_columns = await get_table_columns(conn, "facilities")
insert_fields = ["name", "slug"]
insert_values: list[Any] = [facility_name, normalized_slug]
if "is_published" in facility_columns:
insert_fields.append("is_published")
insert_values.append(bool(data.get("is_published")) if "is_published" in data else False)
placeholders = ", ".join(f"${index}" for index in range(1, len(insert_values) + 1))
created = await conn.fetchrow(
f"""
INSERT INTO facilities ({", ".join(insert_fields)})
VALUES ({placeholders})
RETURNING id
""",
*insert_values,
)
facility_id = int(created["id"])
data["name"] = facility_name
data["slug"] = normalized_slug
if "is_published" in facility_columns and "is_published" not in data:
data["is_published"] = False
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
saved_row = await conn.fetchrow(
"SELECT id, slug, name, is_published FROM facilities WHERE id = $1",
facility_id,
)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
2026-04-26 09:52:05 +02:00
schedule_facility_indexnow_submission_for_fields(
facility_slug,
changed_field_names,
reason="facility create",
)
return {
"status": "success",
"message": "Golfanlegget ble opprettet.",
"facility": format_row(saved_row),
}
2026-04-24 09:17:14 +02:00
@app.get("/api/admin/place-pages/{slug}")
async def get_admin_place_page(slug: str):
normalized_slug = str(slug or "").strip().lower()
if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler.")
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM place_pages WHERE slug = $1",
normalized_slug,
)
if not row:
return {
"slug": normalized_slug,
"factbox_intro_html": "",
"created_at": None,
"updated_at": None,
}
return format_place_page_row(row)
@app.put("/api/admin/place-pages/{slug}")
async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
normalized_slug = str(slug or "").strip().lower()
if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler.")
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO place_pages (slug, factbox_intro_html)
VALUES ($1, $2)
ON CONFLICT (slug) DO UPDATE
SET factbox_intro_html = EXCLUDED.factbox_intro_html,
updated_at = NOW()
RETURNING *
""",
normalized_slug,
request.factbox_intro_html or "",
)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches(include_place_pages=True)
2026-04-24 09:17:14 +02:00
return format_place_page_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' AND section = 'banebesok'
2026-04-13 15:29:43 +02:00
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' AND section = 'banebesok'
2026-04-13 15:29:43 +02:00
""", slug)
if not row:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return format_article_row(row)
@app.get("/api/articles")
async def get_articles(
section: Optional[str] = Query(default="all"),
facility_slug: Optional[str] = Query(default=None),
limit: Optional[int] = Query(default=None, ge=1, le=12),
):
"""Henter publiserte artikler, valgfritt filtrert på seksjon."""
normalized_section = normalize_article_section(section, allow_all=True)
normalized_facility_slug = str(facility_slug or "").strip().lower() or None
clauses = ["status = 'published'"]
params: list[Any] = []
if normalized_section != "all":
params.append(normalized_section)
clauses.append(f"section = ${len(params)}")
if normalized_facility_slug:
params.append(normalized_facility_slug)
clauses.append(f"LOWER(COALESCE(facility_slug, '')) = ${len(params)}")
limit_clause = ""
if limit is not None:
params.append(int(limit))
limit_clause = f"LIMIT ${len(params)}"
query = f"""
SELECT *
FROM articles
WHERE {' AND '.join(clauses)}
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
{limit_clause}
"""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch(query, *params)
return [format_article_row(row) for row in rows]
@app.get("/api/articles/{slug}")
async def get_article(slug: str, section: Optional[str] = Query(default="all")):
"""Henter én publisert artikkel, valgfritt filtrert på seksjon."""
normalized_section = normalize_article_section(section, allow_all=True)
query = """
SELECT *
FROM articles
WHERE slug = $1 AND status = 'published'
{section_clause}
"""
async with app.state.pool.acquire() as conn:
if normalized_section == "all":
row = await conn.fetchrow(query.format(section_clause=""), slug)
else:
row = await conn.fetchrow(
query.format(section_clause="AND section = $2"),
slug,
normalized_section,
)
if not row:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return format_article_row(row)
@app.get("/api/articles/{slug}/comments")
async def get_article_comments(
request: Request,
slug: str,
section: Optional[str] = Query(default=None),
):
normalized_section = normalize_article_section(section) if section else None
viewer = await get_authenticated_public_user(request)
async with app.state.pool.acquire() as conn:
article = await find_published_article_by_slug(conn, slug, normalized_section)
if not article:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
params: list[Any] = [article["id"]]
visibility_clause = "c.status = 'published'"
if viewer:
params.append(viewer["id"])
visibility_clause = "(c.status = 'published' OR (c.status = 'pending' AND c.user_id = $2))"
rows = await conn.fetch(
f"""
SELECT
c.*,
u.display_name,
u.full_name,
u.given_name
FROM article_comments c
JOIN public_users u ON u.id = c.user_id
WHERE c.article_id = $1
AND {visibility_clause}
ORDER BY c.created_at ASC
""",
*params,
)
return {
"auth_configured": get_public_auth_config()["configured"],
"auth_providers": get_public_auth_config(),
"viewer": viewer,
"comments": [format_comment_row(row) for row in rows],
}
@app.post("/api/articles/{slug}/comments")
async def create_article_comment(
request: Request,
payload: PublicCommentCreateRequest,
slug: str,
section: Optional[str] = Query(default=None),
):
viewer = await require_authenticated_public_user(request)
normalized_section = normalize_article_section(section) if section else None
body = str(payload.body or "").strip()
2026-04-18 09:00:16 +02:00
parent_id = payload.parent_id
if len(body) < 3:
raise HTTPException(status_code=400, detail="Kommentaren må være minst 3 tegn.")
if len(body) > 4000:
raise HTTPException(status_code=400, detail="Kommentaren er for lang.")
2026-04-18 09:00:16 +02:00
if parent_id is not None and parent_id <= 0:
raise HTTPException(status_code=400, detail="Ugyldig kommentar å svare på.")
section_label = "Meninger"
parent_author_name: str | None = None
async with app.state.pool.acquire() as conn:
article = await find_published_article_by_slug(conn, slug, normalized_section)
if not article:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
2026-04-18 09:00:16 +02:00
section_label = "Banebesøk" if article["section"] == "banebesok" else "Meninger"
if parent_id is not None:
parent_row = await conn.fetchrow(
"""
SELECT
c.*,
u.display_name,
u.full_name
FROM article_comments c
JOIN public_users u ON u.id = c.user_id
WHERE c.id = $1
AND c.article_id = $2
AND c.status != 'deleted'
LIMIT 1
""",
parent_id,
article["id"],
)
if not parent_row:
raise HTTPException(status_code=404, detail="Fant ikke kommentaren du prøver å svare på.")
parent_author_name = (
parent_row.get("display_name")
or parent_row.get("full_name")
or "TeeOff-leser"
)
row = await conn.fetchrow(
"""
INSERT INTO article_comments (
2026-04-18 09:00:16 +02:00
article_id, user_id, parent_id, body, status, ip_hash, user_agent
) VALUES (
2026-04-18 09:00:16 +02:00
$1, $2, $3, $4, $5, $6, $7
)
RETURNING *
""",
article["id"],
viewer["id"],
2026-04-18 09:00:16 +02:00
parent_id,
body,
PUBLIC_COMMENT_DEFAULT_STATUS,
hash_request_ip(request),
request.headers.get("user-agent"),
)
2026-04-18 09:00:16 +02:00
try:
article_url = f"{build_public_base_url(request)}/{article['section']}/{article['slug']}"
await send_comment_notification_email(
article_title=str(article["title"] or article["slug"]),
article_url=article_url,
article_section=section_label,
comment_body=body,
comment_status=PUBLIC_COMMENT_DEFAULT_STATUS,
commenter_name=str(viewer.get("display_name") or viewer.get("full_name") or "TeeOff-leser"),
commenter_email=(str(viewer.get("email")).strip() if viewer.get("email") else None),
parent_author_name=parent_author_name,
)
except Exception as exc:
print(f"Kunne ikke sende kommentarvarsel: {exc}")
return {
"status": "success",
"detail": (
"Kommentaren er publisert."
if PUBLIC_COMMENT_DEFAULT_STATUS == "published"
else "Kommentaren er mottatt og venter på godkjenning."
),
"comment": format_comment_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.get("/api/admin/articles/by-slug/{slug}")
async def get_admin_article_by_slug(slug: str, section: Optional[str] = Query(default="all")):
normalized_section = normalize_article_section(section, allow_all=True)
query = """
SELECT *
FROM articles
WHERE slug = $1
{section_clause}
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 1
"""
async with app.state.pool.acquire() as conn:
if normalized_section == "all":
row = await conn.fetchrow(query.format(section_clause=""), slug)
else:
row = await conn.fetchrow(
query.format(section_clause="AND section = $2"),
slug,
normalized_section,
)
if not row:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return format_article_row(row)
2026-04-13 15:29:43 +02:00
@app.post("/api/admin/articles")
async def upsert_admin_article(request: ArticleUpsertRequest):
section = normalize_article_section(request.section)
2026-04-13 15:29:43 +02:00
status = normalize_article_status(request.status)
2026-04-18 09:00:16 +02:00
requested_slug = request.slug.strip()
2026-04-13 15:29:43 +02:00
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()
fallback_hero_images = sanitize_hero_images(request.hero_images)
media_gallery = sanitize_article_media(request.media_gallery, request.title.strip())
if not media_gallery and fallback_hero_images:
media_gallery = build_media_gallery_from_hero_images(fallback_hero_images)
featured_media_id = sanitize_featured_media_id(request.featured_media_id, media_gallery)
hero_images = build_hero_images_from_media_gallery(media_gallery, fallback_hero_images, featured_media_id)
2026-04-13 15:29:43 +02:00
async with app.state.pool.acquire() as conn:
2026-04-18 09:00:16 +02:00
previous_row = await conn.fetchrow(
"SELECT slug, section, status FROM articles WHERE slug = $1",
requested_slug,
)
2026-04-13 15:29:43 +02:00
row = await conn.fetchrow("""
INSERT INTO articles (
section, slug, title, description, excerpt, eyebrow, location_label,
2026-04-13 15:29:43 +02:00
facility_name, facility_slug, author_name, status, hero_images,
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
2026-04-13 15:29:43 +02:00
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12::jsonb,
$13::jsonb, $14, $15, $16, $17, $18, $19
2026-04-13 15:29:43 +02:00
)
ON CONFLICT (slug) DO UPDATE SET
section = EXCLUDED.section,
2026-04-13 15:29:43 +02:00
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,
media_gallery = EXCLUDED.media_gallery,
featured_media_id = EXCLUDED.featured_media_id,
2026-04-13 15:29:43 +02:00
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 *
""",
section,
2026-04-18 09:00:16 +02:00
requested_slug,
2026-04-13 15:29:43 +02:00
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),
json.dumps(media_gallery),
featured_media_id,
2026-04-13 15:29:43 +02:00
request.content_html or "",
(request.source_url or "").strip() or None,
(request.source_label or "").strip() or None,
published_at,
updated_at,
)
2026-04-18 09:00:16 +02:00
saved_article = format_article_row(row)
previous_article = format_article_row(previous_row) if previous_row else None
schedule_indexnow_submission(
collect_article_indexnow_urls(previous_article=previous_article, current_article=saved_article),
reason="admin article upsert",
)
return saved_article
2026-04-13 15:29:43 +02:00
@app.delete("/api/admin/articles/{article_id}")
async def delete_admin_article(article_id: int):
async with app.state.pool.acquire() as conn:
2026-04-18 09:00:16 +02:00
deleted = await conn.fetchrow(
"DELETE FROM articles WHERE id = $1 RETURNING slug, section, status",
article_id,
)
2026-04-13 15:29:43 +02:00
if not deleted:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
2026-04-18 09:00:16 +02:00
deleted_article = format_article_row(deleted)
schedule_indexnow_submission(
collect_article_indexnow_urls(previous_article=deleted_article),
reason="admin article delete",
)
2026-04-13 15:29:43 +02:00
return {"status": "success"}
2026-03-05 05:18:03 +01:00
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
2026-04-26 09:52:05 +02:00
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate, http_request: Request):
2026-03-05 05:18:03 +01:00
"""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:
2026-04-26 09:52:05 +02:00
current_course = await conn.fetchrow(
"SELECT id, facility_id, status FROM courses WHERE id = $1 AND facility_id = $2",
c.id,
facility_id,
)
if not current_course:
continue
old_status = current_course["status"] or "ukjent"
new_status = c.status
if str(old_status or "").strip().lower() == str(new_status or "").strip().lower():
continue
await log_course_status_change(
conn,
course_id=int(current_course["id"]),
facility_id=int(current_course["facility_id"]),
old_status=old_status,
new_status=new_status,
change_source="manual",
changed_by=getattr(http_request.state, "admin_username", None),
)
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c.id)
2026-03-05 09:25:15 +01:00
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
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="Kunne ikke oppdatere skrapeinnstillingene")
2026-03-05 05:18:03 +01:00
# --- 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)."""
2026-04-26 09:52:05 +02:00
data = apply_legacy_facility_field_aliases(await request.json())
2026-04-14 16:31:28 +02:00
async with app.state.pool.acquire() as conn:
2026-04-26 09:52:05 +02:00
async with conn.transaction():
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
2026-04-21 07:21:46 +02:00
2026-04-26 09:52:05 +02:00
schedule_facility_indexnow_submission_for_fields(
facility_slug,
changed_field_names,
2026-04-18 09:00:16 +02:00
reason="facility full update",
)
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
2026-04-21 07:21:46 +02:00
@app.delete("/api/admin/facilities/{facility_id}")
async def delete_facility(facility_id: int):
"""Sletter et anlegg permanent med tilhørende baner og hull."""
async with app.state.pool.acquire() as conn:
deleted = await conn.fetchrow(
"DELETE FROM facilities WHERE id = $1 RETURNING slug, name",
facility_id,
)
if not deleted:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
deleted_slug = str(deleted["slug"] or "").strip()
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
2026-04-21 07:21:46 +02:00
schedule_indexnow_submission(
collect_facility_indexnow_urls([deleted_slug], extra_paths=["/golfbaner", "/medlemskap", "/vtg"]),
reason="facility delete",
)
return {
"status": "success",
"message": f"{deleted['name']} ble slettet.",
"slug": deleted_slug,
}
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-26 09:52:05 +02:00
@app.get("/api/admin/course-status-history")
async def get_admin_course_status_history(
changed_on: Optional[date] = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
):
"""Henter banestatusendringer for en gitt dato, med Oslo som standard for 'i dag'."""
async with app.state.pool.acquire() as conn:
return await list_course_status_history(
conn,
changed_on=changed_on or get_oslo_today(),
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:
print(f"❌ Health check feilet: {type(e).__name__}")
return {"status": "unhealthy", "error": "database_unavailable"}
2026-03-02 09:56:37 +01:00
# --- 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."""
2026-04-18 09:00:16 +02:00
facility_ids = [approval.facility_id for approval in request.approvals]
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)
2026-04-18 09:00:16 +02:00
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
2026-04-18 09:00:16 +02:00
schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/medlemskap", "/golfbaner"]),
reason="membership bulk approval",
)
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."""
2026-04-15 08:15:53 +02:00
# Sikkerhet: Tillat KUN disse URL-/tekstfeltene for hurtigredigering
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'greenfee_url', 'golfpakker_url', 'vtg_lenke', 'scrape_status_selector', 'footnote', 'website_url']
2026-03-12 13:39:10 +01:00
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:
2026-04-18 09:00:16 +02:00
facility_slug = str(
await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
).strip()
2026-04-15 08:15:53 +02:00
if request.field == 'footnote':
normalized_value = str(request.value or '').strip() or None
2026-04-19 09:49:47 +02:00
footnote_updated_at = datetime.utcnow() if normalized_value else None
2026-04-15 08:15:53 +02:00
await conn.execute(
"""
UPDATE facilities
SET footnote = $1,
2026-04-19 09:49:47 +02:00
footnote_updated_at = $2
WHERE id = $3
2026-04-15 08:15:53 +02:00
""",
normalized_value,
2026-04-19 09:49:47 +02:00
footnote_updated_at,
2026-04-15 08:15:53 +02:00
facility_id
)
else:
# 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)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
2026-04-18 09:00:16 +02:00
schedule_indexnow_submission(
collect_facility_indexnow_urls([facility_slug], extra_paths=["/golfbaner"]),
reason=f"facility quick edit ({request.field})",
)
2026-03-12 13:39:10 +01:00
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."""
2026-04-18 09:00:16 +02:00
facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn:
async with conn.transaction():
2026-04-17 09:25:32 +02:00
facility_columns = await get_table_columns(conn, "facilities")
has_cooperating_clubs = "cooperating_clubs" in facility_columns
for approval in request.approvals:
2026-04-15 08:15:53 +02:00
draft_row = await conn.fetchrow(
"SELECT greenfee_draft FROM facilities WHERE id = $1",
approval.facility_id
)
draft_payload = format_row(draft_row) if draft_row else {}
draft_data = draft_payload.get("greenfee_draft", {}) if isinstance(draft_payload, dict) else {}
suggested_clubs = draft_data.get("foreslatt_avtaleklubber", []) if isinstance(draft_data, dict) else []
cooperating_club_slugs = await resolve_cooperating_club_slugs(
conn,
suggested_clubs if isinstance(suggested_clubs, list) else [],
exclude_facility_id=approval.facility_id,
)
2026-04-17 09:25:32 +02:00
if has_cooperating_clubs:
await conn.execute("""
UPDATE facilities
SET greenfee = $1::jsonb,
cooperating_clubs = CASE
WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs
ELSE $2::jsonb
END,
greenfee_updated_at = NOW(),
greenfee_draft = NULL
WHERE id = $3
""", json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id)
else:
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)
2026-04-18 09:00:16 +02:00
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
2026-04-18 09:00:16 +02:00
schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
reason="greenfee bulk approval",
)
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 ventende VTG-forslag for innhold eller kurs."""
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_content_draft,
vtg_courses_draft,
CASE
WHEN vtg_content_draft IS NOT NULL AND vtg_content_draft::text != '{}'
THEN TRUE
ELSE FALSE
END AS has_vtg_content_draft,
CASE
WHEN vtg_courses_draft IS NOT NULL
THEN TRUE
ELSE FALSE
END AS has_vtg_courses_draft
FROM facilities
WHERE (
vtg_content_draft IS NOT NULL AND vtg_content_draft::text != '{}'
) OR vtg_courses_draft IS NOT NULL
ORDER BY name ASC
""")
return [format_row(row) for row in rows]
@app.post("/api/admin/vtg/approve-content-bulk")
async def approve_vtg_content_bulk(request: BulkVtgContentRequest):
facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await conn.execute(
"""
UPDATE facilities
SET vtg_pris = $1,
vtg_beskrivelse = $2,
vtg_content_updated_at = NOW(),
vtg_updated_at = NOW(),
vtg_content_draft = NULL,
vtg_draft = CASE
WHEN vtg_courses_draft IS NULL THEN NULL
ELSE vtg_draft
END
WHERE id = $3
""",
approval.vtg_pris,
approval.vtg_beskrivelse,
approval.facility_id,
)
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
reason="vtg content bulk approval",
)
return {"status": "success"}
@app.post("/api/admin/vtg/approve-courses-bulk")
async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await replace_facility_vtg_courses(conn, approval.facility_id, approval.vtg_datoer)
await conn.execute(
"""
UPDATE facilities
SET vtg_courses_updated_at = NOW(),
vtg_updated_at = NOW(),
vtg_courses_draft = NULL,
vtg_draft = CASE
WHEN vtg_content_draft IS NULL THEN NULL
ELSE vtg_draft
END
WHERE id = $1
""",
approval.facility_id,
)
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
reason="vtg courses bulk approval",
)
return {"status": "success"}
@app.post("/api/admin/vtg/approve-bulk")
async def approve_vtg_bulk(request: BulkVtgRequest):
"""Kompatibilitets-endepunkt som godkjenner både innhold og kurs."""
2026-04-18 09:00:16 +02:00
facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await replace_facility_vtg_courses(conn, approval.facility_id, approval.vtg_datoer)
await conn.execute(
"""
UPDATE facilities
SET vtg_pris = $1,
vtg_beskrivelse = $2,
vtg_content_updated_at = NOW(),
vtg_courses_updated_at = NOW(),
vtg_updated_at = NOW(),
vtg_content_draft = NULL,
vtg_courses_draft = NULL,
vtg_draft = NULL
WHERE id = $3
""",
approval.vtg_pris,
approval.vtg_beskrivelse,
approval.facility_id,
)
2026-04-18 09:00:16 +02:00
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
2026-04-18 09:00:16 +02:00
schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
reason="vtg bulk approval",
)
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-04-15 08:15:53 +02:00
@app.get("/api/admin/golfpakker/drafts")
async def get_golfpakker_drafts():
"""Henter alle anlegg som har et ventende golfpakke-forslag."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, slug, website_url, golfpakker_url, golfpakker, golfpakker_draft
FROM facilities
WHERE golfpakker_draft IS NOT NULL
AND golfpakker_draft::text != '{}'
ORDER BY name ASC
""")
return [format_row(row) for row in rows]
@app.post("/api/admin/golfpakker/approve-bulk")
async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest):
"""Godkjenner AI-forslag for golfpakker og sletter utkastet."""
2026-04-18 09:00:16 +02:00
facility_ids = [approval.facility_id for approval in request.approvals]
2026-04-15 08:15:53 +02:00
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await conn.execute("""
UPDATE facilities
SET golfpakker = $1::jsonb,
golfpakker_updated_at = NOW(),
golfpakker_draft = NULL
WHERE id = $2
""", json.dumps(approval.golfpakker), approval.facility_id)
2026-04-18 09:00:16 +02:00
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
2026-04-26 11:29:35 +02:00
invalidate_public_api_caches()
2026-04-18 09:00:16 +02:00
schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
reason="golfpakker bulk approval",
)
2026-04-15 08:15:53 +02:00
return {"status": "success"}
@app.post("/api/admin/run-golfpakker-scraper")
async def run_golfpakker_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
"""Tar imot IDer for golfpakkeskraping og legger jobben i kø."""
print(f"📡 API mottok forespørsel om golfpakkeskraping for IDer: {request.facility_ids}")
return await queue_scrape_job("golfpakker", 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)