6347 lines
229 KiB
Python
6347 lines
229 KiB
Python
"""
|
|
TEE OFF BACKEND API v3.8.0 - KOBLET PÅ FULL ADMIN REDIGERING
|
|
---------------------------------------------------------------------------
|
|
REGEL 1: Bruk str (ikke string) for type-hinting.
|
|
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
|
|
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
|
|
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
|
|
LOV: Aldri trunker eller slett logikk for "effektivitet".
|
|
---------------------------------------------------------------------------
|
|
"""
|
|
|
|
from fastapi import FastAPI, HTTPException, Response, Request, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from contextlib import asynccontextmanager
|
|
import asyncio
|
|
import asyncpg
|
|
import json
|
|
import pyotp
|
|
import os
|
|
import re
|
|
import secrets
|
|
import hashlib
|
|
import smtplib
|
|
import time
|
|
import unicodedata
|
|
from datetime import datetime, date, timedelta
|
|
from email.message import EmailMessage
|
|
from pathlib import Path
|
|
from jose import jwt, JWTError
|
|
from passlib.context import CryptContext
|
|
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
|
|
|
|
from scrape_jobs import (
|
|
SCRAPE_JOB_TYPES,
|
|
enqueue_scrape_job,
|
|
ensure_scrape_jobs_table,
|
|
list_scrape_jobs,
|
|
)
|
|
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, get_invalid_vtg_course_labels, normalize_vtg_course_rows
|
|
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
|
|
|
|
# --- 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()
|
|
COMMENT_NOTIFICATION_TO_EMAIL = os.getenv(
|
|
"COMMENT_NOTIFICATION_TO_EMAIL",
|
|
CONTACT_FORM_TO_EMAIL,
|
|
).strip()
|
|
FACILITY_RATING_NOTIFICATION_TO_EMAIL = os.getenv(
|
|
"FACILITY_RATING_NOTIFICATION_TO_EMAIL",
|
|
COMMENT_NOTIFICATION_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()
|
|
|
|
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
|
|
PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS = 3600
|
|
|
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
|
|
|
|
|
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
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
def initialize_public_api_caches() -> None:
|
|
app.state.public_facilities_cache = {}
|
|
app.state.public_facility_detail_cache = {}
|
|
app.state.public_place_page_cache = {}
|
|
app.state.public_site_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, include_site_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()
|
|
|
|
if include_site_pages:
|
|
site_page_cache = getattr(app.state, "public_site_page_cache", None)
|
|
if isinstance(site_page_cache, dict):
|
|
site_page_cache.clear()
|
|
|
|
|
|
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:
|
|
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"
|
|
|
|
|
|
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 collect_page_indexnow_urls(paths: list[str]) -> list[str]:
|
|
urls: list[str] = []
|
|
for path in paths:
|
|
public_url = build_absolute_public_url(path)
|
|
if public_url:
|
|
urls.append(public_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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
async def send_facility_rating_notification_email(
|
|
*,
|
|
facility_name: str,
|
|
facility_url: str,
|
|
reviewer_name: str,
|
|
reviewer_email: str | None,
|
|
quality_rating: int,
|
|
conditions_rating: int,
|
|
hospitality_rating: int,
|
|
overall_rating: float,
|
|
rating_count: int,
|
|
quality_average: float | None,
|
|
conditions_average: float | None,
|
|
hospitality_average: float | None,
|
|
overall_average: float | None,
|
|
is_new_rating: bool,
|
|
ip_hash: str | None,
|
|
) -> None:
|
|
if not (is_magic_link_configured() and FACILITY_RATING_NOTIFICATION_TO_EMAIL):
|
|
return
|
|
|
|
subject = f"[TeeOff Vurdering] {facility_name}"
|
|
body = (
|
|
"Ny brukervurdering på TeeOff.no\n\n"
|
|
f"Golfanlegg: {facility_name}\n"
|
|
f"Lenke: {facility_url}\n"
|
|
f"Hendelse: {'Ny vurdering' if is_new_rating else 'Oppdatert vurdering'}\n"
|
|
f"Bruker: {reviewer_name}\n"
|
|
f"E-post: {reviewer_email or 'ikke tilgjengelig'}\n"
|
|
f"IP-hash: {ip_hash or 'ukjent'}\n\n"
|
|
"Innsendt vurdering:\n"
|
|
f"Kvalitet på anlegg: {quality_rating}/5\n"
|
|
f"Forhold: {conditions_rating}/5\n"
|
|
f"Gjestfrihet: {hospitality_rating}/5\n"
|
|
f"Snitt for denne vurderingen: {overall_rating:.1f}/5\n\n"
|
|
"Oppsummert etter lagring:\n"
|
|
f"Antall vurderinger: {rating_count}\n"
|
|
f"Gjennomsnitt kvalitet på anlegg: {quality_average if quality_average is not None else 'ikke tilgjengelig'}/5\n"
|
|
f"Gjennomsnitt forhold: {conditions_average if conditions_average is not None else 'ikke tilgjengelig'}/5\n"
|
|
f"Gjennomsnitt gjestfrihet: {hospitality_average if hospitality_average is not None else 'ikke tilgjengelig'}/5\n"
|
|
f"Totalt gjennomsnitt: {overall_average if overall_average is not None else 'ikke tilgjengelig'}/5\n"
|
|
)
|
|
|
|
def _send() -> None:
|
|
mail = EmailMessage()
|
|
mail["From"] = PUBLIC_FROM_EMAIL
|
|
mail["To"] = FACILITY_RATING_NOTIFICATION_TO_EMAIL
|
|
if reviewer_email:
|
|
mail["Reply-To"] = reviewer_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)
|
|
|
|
|
|
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
|
|
|
|
# --- PYDANTIC MODELLER ---
|
|
class CourseStatusUpdate(BaseModel):
|
|
id: int
|
|
status: str
|
|
|
|
class ScrapeSettingsUpdate(BaseModel):
|
|
scrape_method: Optional[str] = None
|
|
scrape_status_url: Optional[str] = None
|
|
scrape_status_selector: Optional[str] = None
|
|
ai_instruction: Optional[str] = None
|
|
courses: Optional[List[CourseStatusUpdate]] = []
|
|
|
|
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
|
|
class ScrapeRunRequest(BaseModel):
|
|
facility_ids: List[int]
|
|
|
|
class MembershipDraftApproval(BaseModel):
|
|
facility_id: int
|
|
navn_standard_medlemskap: Optional[str] = None
|
|
standard_medlemskap: Optional[int] = None
|
|
standard_medlemskap_kommentarer: Optional[str] = None
|
|
navn_rimeligste_alternativ: Optional[str] = None
|
|
rimeligste_alternativ: Optional[int] = None
|
|
|
|
class BulkApprovalRequest(BaseModel):
|
|
approvals: List[MembershipDraftApproval]
|
|
|
|
class QuickEditRequest(BaseModel):
|
|
field: str
|
|
value: str
|
|
|
|
|
|
class FacilityVisibilityRequest(BaseModel):
|
|
is_published: bool
|
|
|
|
|
|
class PlacePageUpsertRequest(BaseModel):
|
|
factbox_intro_html: Optional[str] = ""
|
|
meta_title: Optional[str] = None
|
|
meta_description: Optional[str] = None
|
|
|
|
|
|
class SitePageSeoUpsertRequest(BaseModel):
|
|
meta_title: Optional[str] = None
|
|
meta_description: Optional[str] = None
|
|
|
|
|
|
class SitePageUpsertRequest(BaseModel):
|
|
eyebrow: Optional[str] = None
|
|
title: Optional[str] = None
|
|
hero_image_url: Optional[str] = None
|
|
intro_html: Optional[str] = ""
|
|
body_html: Optional[str] = ""
|
|
meta_title: Optional[str] = None
|
|
meta_description: Optional[str] = None
|
|
|
|
|
|
class SimulatorOperatorUpsertRequest(BaseModel):
|
|
name: str
|
|
slug: Optional[str] = None
|
|
website_url: Optional[str] = None
|
|
logo_url: Optional[str] = None
|
|
description: Optional[str] = None
|
|
meta_title: Optional[str] = None
|
|
meta_description: Optional[str] = None
|
|
is_published: bool = False
|
|
|
|
|
|
class SimulatorVenueUpsertRequest(BaseModel):
|
|
operator_id: Optional[int] = None
|
|
facility_id: Optional[int] = None
|
|
name: str
|
|
slug: Optional[str] = None
|
|
venue_type: str
|
|
description: Optional[str] = None
|
|
city: Optional[str] = None
|
|
county: Optional[str] = None
|
|
address: Optional[str] = None
|
|
postal_code: Optional[str] = None
|
|
lat: Optional[float] = None
|
|
lng: Optional[float] = None
|
|
website_url: Optional[str] = None
|
|
booking_url: Optional[str] = None
|
|
phone: Optional[str] = None
|
|
email: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
meta_title: Optional[str] = None
|
|
meta_description: Optional[str] = None
|
|
simulator_systems: Optional[List[str]] = []
|
|
bay_count: Optional[int] = Field(default=None, ge=0)
|
|
lessons_available: Optional[bool] = None
|
|
club_fitting: Optional[bool] = None
|
|
food_and_drink: Optional[bool] = None
|
|
serves_alcohol: Optional[bool] = None
|
|
drop_in: Optional[bool] = None
|
|
membership_required: Optional[bool] = None
|
|
opening_hours: Optional[str] = None
|
|
price_from: Optional[float] = Field(default=None, ge=0)
|
|
season: Optional[str] = None
|
|
is_published: bool = False
|
|
|
|
|
|
class GreenfeeApproval(BaseModel):
|
|
facility_id: int
|
|
greenfee: List[dict]
|
|
|
|
|
|
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]
|
|
|
|
|
|
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]
|
|
|
|
|
|
class BulkGolfpakkerRequest(BaseModel):
|
|
approvals: List[GolfpakkerApproval]
|
|
|
|
|
|
class AdminPasswordConfirm(BaseModel):
|
|
password: str
|
|
|
|
|
|
class ArticleUpsertRequest(BaseModel):
|
|
section: Optional[str] = "banebesok"
|
|
slug: str
|
|
title: str
|
|
meta_title: Optional[str] = None
|
|
meta_description: Optional[str] = None
|
|
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
|
|
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
|
|
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
|
|
|
|
|
|
class PublicFacilityFeedbackRequest(BaseModel):
|
|
facility_id: int
|
|
name: str
|
|
email: str
|
|
message: str
|
|
website: Optional[str] = ""
|
|
started_at: Optional[int] = None
|
|
|
|
|
|
LEGACY_FACILITY_FIELD_ALIASES = {
|
|
'vtg_presentasjon': 'vtg_beskrivelse',
|
|
'vtg_kursdatoer': 'vtg_datoer',
|
|
}
|
|
LEGACY_TEE_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']
|
|
|
|
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', 'videos',
|
|
'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',
|
|
'camper_parking',
|
|
'meta_title', 'meta_description',
|
|
'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',
|
|
}
|
|
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', 'camper_parking', 'meta_title', 'meta_description',
|
|
'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 | {
|
|
'has_golfpakker',
|
|
'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'}
|
|
SIMULATOR_VENUE_TYPES = {
|
|
"golfanlegg",
|
|
"simulatorsenter",
|
|
"pub",
|
|
"butikk",
|
|
"hotell",
|
|
"annet",
|
|
}
|
|
# --- FUNKSJONER ---
|
|
def format_row(row):
|
|
"""
|
|
Vasker data fra databasen:
|
|
1. Konverterer datoer til ISO-format.
|
|
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
|
|
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
|
|
"""
|
|
if row is None:
|
|
return None
|
|
|
|
d = dict(row)
|
|
|
|
for key in [
|
|
'status_updated_at', 'created_at', 'slope_valid_until',
|
|
'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at',
|
|
'golfpakker_updated_at', 'vtg_content_updated_at', 'vtg_courses_updated_at'
|
|
]:
|
|
if isinstance(d.get(key), (date, datetime)):
|
|
d[key] = d[key].isoformat()
|
|
|
|
json_list_fields = [
|
|
'course_statuses', 'courses', 'gallery', 'videos', 'greenfee',
|
|
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer',
|
|
'weather_forecast', 'vtg_courses_draft'
|
|
]
|
|
json_dict_fields = [
|
|
'amenities', 'vtg', 'nsg_data', 'golfamore_data',
|
|
'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft', 'hole_par_counts',
|
|
'vtg_content_draft'
|
|
]
|
|
|
|
for field in json_list_fields:
|
|
if field in d:
|
|
val = d[field]
|
|
if val is None:
|
|
d[field] = []
|
|
elif isinstance(val, str):
|
|
try:
|
|
d[field] = json.loads(val)
|
|
except:
|
|
d[field] = []
|
|
elif not isinstance(val, list):
|
|
d[field] = []
|
|
|
|
for field in json_dict_fields:
|
|
if field in d:
|
|
val = d[field]
|
|
if val is None:
|
|
d[field] = {}
|
|
elif isinstance(val, str):
|
|
try:
|
|
d[field] = json.loads(val)
|
|
except:
|
|
d[field] = {}
|
|
elif not isinstance(val, dict):
|
|
d[field] = {}
|
|
|
|
return d
|
|
|
|
|
|
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
|
|
|
|
|
|
def ensure_valid_vtg_course_rows(approvals: list[Any]) -> None:
|
|
invalid_entries: list[str] = []
|
|
for approval in approvals:
|
|
invalid_labels = get_invalid_vtg_course_labels(getattr(approval, "vtg_datoer", None))
|
|
if invalid_labels:
|
|
preview = ", ".join(invalid_labels[:3])
|
|
if len(invalid_labels) > 3:
|
|
preview = f"{preview}, +{len(invalid_labels) - 3} til"
|
|
invalid_entries.append(
|
|
f"anlegg {getattr(approval, 'facility_id', 'ukjent')}: {preview}"
|
|
)
|
|
|
|
if invalid_entries:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
"Kun kurs med gyldige datoer kan godkjennes. Ugyldige kursrader funnet for "
|
|
+ "; ".join(invalid_entries)
|
|
),
|
|
)
|
|
|
|
|
|
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 "")
|
|
d["meta_title"] = str(d.get("meta_title") or "")
|
|
d["meta_description"] = str(d.get("meta_description") or "")
|
|
return d
|
|
|
|
|
|
def format_site_page_seo_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["page_key"] = str(d.get("page_key") or "")
|
|
d["meta_title"] = str(d.get("meta_title") or "")
|
|
d["meta_description"] = str(d.get("meta_description") or "")
|
|
return d
|
|
|
|
|
|
LEGACY_SITE_PAGE_ARTICLE_SLUGS = {
|
|
"turneringer": "note-to-self-lenker-til-viktige-turneringer-i-golfbox",
|
|
}
|
|
|
|
SITE_PAGE_CONFIGS: dict[str, dict[str, str]] = {
|
|
"turneringer": {
|
|
"path": "/turneringer",
|
|
"eyebrow": "Turneringer",
|
|
"title": "Lenker til viktige turneringer i Golfbox",
|
|
"hero_image_url": "",
|
|
"intro_html": (
|
|
"<p>Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox "
|
|
"(og andre steder). God golfsesong!</p>"
|
|
),
|
|
"body_html": "",
|
|
"meta_title": "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no",
|
|
"meta_description": (
|
|
"Vanskelig å finne frem i Golfbox? Vi samler terminlister for Narvesen Tour "
|
|
"og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!"
|
|
),
|
|
},
|
|
"klubbnummer": {
|
|
"path": "/klubbnummer",
|
|
"eyebrow": "Klubbnummer",
|
|
"title": "Klubbnummer i Golfbox",
|
|
"hero_image_url": "",
|
|
"intro_html": (
|
|
"<p>I booking-vinduet i Golfbox (eller Gimmie) er det ofte vanskelig å se "
|
|
"hvilken klubb spillere er fra. Om du ønsker kan du da bruke denne tabellen som oppslagsverk.</p>"
|
|
),
|
|
"body_html": "",
|
|
"meta_title": "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no",
|
|
"meta_description": (
|
|
"Hvilken klubb tilhører nummeret? Se komplett og sorterbar oversikt over alle norske "
|
|
"golfklubber og deres NGF-klubbnummer for bruk i Golfbox og Gimmie."
|
|
),
|
|
},
|
|
"om": {
|
|
"path": "/om",
|
|
"eyebrow": "FAQ / Om",
|
|
"title": "Hva TeeOff er, og hvorfor siden finnes",
|
|
"hero_image_url": "",
|
|
"intro_html": (
|
|
"<p>Kortversjonen er fortsatt den samme: TeeOff bruker mye tid på å samle, rydde "
|
|
"og presentere norske golfanlegg på en ordentlig måte, slik at det blir lettere "
|
|
"å finne ut hvor man faktisk har lyst til å spille.</p>"
|
|
),
|
|
"body_html": """
|
|
<section>
|
|
<h2>Hvorfor dette nettstedet?</h2>
|
|
<p>TeeOff startet i 2015 fordi det var unødvendig vanskelig å finne ut hvilke golfbaner som ligger hvor, hva de tilbyr, og hvilken bane som faktisk passer til turen du vurderer å ta. Ambisjonen var enkel: samle norske golfanlegg på ett sted og gjøre dem lettere å finne, forstå og sammenligne.</p>
|
|
<p>Kjernen er den samme i dag: kjærlighet til golf, lysten til å vise hvor bra det er å spille i Norge, og behovet for en oversikt som faktisk er nyttig for greenfeespillere, klubbfolk og andre som vil oppdage nye baner.</p>
|
|
<p>TeeOff er ikke laget for å erstatte klubbenes egne nettsider. Målet er å gjøre klubbene enklere å finne, og å gjøre terskelen lavere for å dra og spille et nytt sted.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Hvilken informasjon finnes om banene?</h2>
|
|
<ul>
|
|
<li>adresse og plassering på kart</li>
|
|
<li>bilder og video når det finnes</li>
|
|
<li>kontaktinformasjon, hjemmeside og sosiale medier</li>
|
|
<li>banestatus, vær, flyfoto og turneringslenker</li>
|
|
<li>banebeskrivelse og praktisk info om fasiliteter</li>
|
|
<li>head pro, greenfee, medlemskap og Veien til Golf</li>
|
|
<li>scorekort, slope og annen nyttig banedata</li>
|
|
<li>redaksjonelt innhold som banebesøk og meninger når det finnes</li>
|
|
</ul>
|
|
</section>
|
|
<section>
|
|
<h2>Hvor kommer informasjonen fra?</h2>
|
|
<p>Innholdet hentes fra klubbenes egne nettsider, Golfbox, Shotzoom, sosiale medier, direkte kontakt med klubbene og i noen tilfeller egne besøk og manuell research.</p>
|
|
<p>Ambisjonen er at informasjonen skal være korrekt, oppdatert og praktisk anvendelig. Når noe endrer seg, er TeeOff avhengig av gode kilder og raske tilbakemeldinger. Oppdager du feil, er det derfor bare å si fra.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Hvem holder TeeOff oppdatert?</h2>
|
|
<p>TeeOff bygger videre på et mangeårig arbeid med å samle og rydde informasjon om norske golfanlegg. Den nye løsningen er laget for å gjøre det enklere å holde flere typer klubbdata oppdatert enn tidligere.</p>
|
|
<p>Har du informasjon som bør endres, bilder som bør brukes, eller tips til innhold, kan du sende det inn via kontaktsiden.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Hvordan bytte toppbildet på en klubbside?</h2>
|
|
<p>Hvis du representerer en klubb og vil bytte hovedbildet som presenterer banen, er det bare å ta kontakt og sende over et godt bilde i bredt format. Fotograf krediteres når informasjonen følger med.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Koster dette noe?</h2>
|
|
<p>TeeOff er gratis å bruke for både golfspillere og klubber. Det ligger mye arbeid bak å samle, rydde og presentere informasjonen, men selve synligheten på TeeOff er ikke låst bak betaling.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Drukner klubbene i spam hvis e-postadressen vises?</h2>
|
|
<p>Nei, det er ikke meningen. Kontaktinformasjon publiseres fordi den skal være nyttig for vanlige mennesker, samtidig som løsningene rundt kontaktsiden og systemet er laget for å redusere automatisert misbruk og spam.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Hva betyr TeeOff for klubbene egentlig?</h2>
|
|
<p>Bruksmønsteret har lenge vært ganske tydelig: mange bruker TeeOff når de faktisk vurderer å dra og spille. De sammenligner anlegg, ser på praktisk informasjon, sjekker kart, turneringer og detaljer før de bestemmer seg.</p>
|
|
<p>Det er den viktigste verdien TeeOff kan gi klubbene også i dag: gjøre det lettere for flere å oppdage nye golfanlegg, finne relevant informasjon og komme seg ut på banen.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Sporing og analyse</h2>
|
|
<p>TeeOff bruker analyseverktøy for å forstå hvilke sider som brukes, hvordan besøkende navigerer og hva som bør forbedres. I dag skjer dette med Matomo.</p>
|
|
<p>Hvis du vil vite mer om personvern, cookies og analyse, finnes det en egen side for dette.</p>
|
|
<p><a href="/personvern-og-cookies">Les om personvern og cookies</a></p>
|
|
</section>
|
|
<section>
|
|
<h2>Turneringer, kurs og andre tilbud</h2>
|
|
<p>Klubber har ofte behov for å løfte frem turneringer, VTG-kurs og andre tilbud. TeeOff har derfor egne flater for dette, og slike ting kan også løftes frem sammen med den enkelte klubbprofilen.</p>
|
|
<p><a href="/turneringer">Se turneringer</a><br /><a href="/vtg">Se Veien til Golf</a><br /><a href="/kontakt">Kontakt TeeOff</a></p>
|
|
</section>
|
|
""".strip(),
|
|
"meta_title": "FAQ / Om TeeOff",
|
|
"meta_description": (
|
|
"Hvorfor TeeOff finnes, hvilken informasjon som samles om norske golfanlegg, "
|
|
"og hvordan siden brukes av både golfspillere og klubber."
|
|
),
|
|
},
|
|
"personvern-og-cookies": {
|
|
"path": "/personvern-og-cookies",
|
|
"eyebrow": "Personvern",
|
|
"title": "Personvern og cookies",
|
|
"hero_image_url": "",
|
|
"intro_html": (
|
|
"<p>Denne siden forklarer kort hvilke opplysninger TeeOff behandler, hvorfor vi "
|
|
"gjør det, og hvordan cookies brukes på nettsiden.</p>"
|
|
),
|
|
"body_html": """
|
|
<section>
|
|
<h2>Hva vi lagrer</h2>
|
|
<p>TeeOff lagrer i hovedsak opplysninger som er nødvendige for å vise klubbdata, publisere innhold og håndtere henvendelser fra brukere.</p>
|
|
<p>Hvis du bruker kontaktskjemaet, lagres opplysningene du selv sender inn for å kunne besvare henvendelsen. Hvis du kommenterer artikler eller bruker innloggingsfunksjoner, behandles de opplysningene som er nødvendige for å autentisere deg og vise innholdet.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Cookies</h2>
|
|
<p>TeeOff bruker cookies til noen få, konkrete formål:</p>
|
|
<ul>
|
|
<li>innlogging og sesjonshåndtering for administratorer</li>
|
|
<li>innlogging for offentlige brukerfunksjoner som kommentarer</li>
|
|
<li>måling av trafikk og bruksmønstre via Matomo</li>
|
|
</ul>
|
|
<p>Nødvendige cookies brukes for at nettsiden skal fungere. Analysecookies brukes for å forstå hvordan nettsiden brukes og forbedre innhold og funksjonalitet.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Analyse med Matomo</h2>
|
|
<p>TeeOff bruker Matomo for å måle trafikk på nettsiden. Formålet er å forstå hvilke sider som brukes, hvordan besøkende navigerer og hvor innhold kan forbedres.</p>
|
|
<p>Matomo-instansen kjøres på <strong>analyse.envide.no</strong>. Admin-områdene spores ikke på samme måte som vanlige publikumssider.</p>
|
|
</section>
|
|
<section>
|
|
<h2>Kontakt om personvern</h2>
|
|
<p>Hvis du har spørsmål om personvern, cookies eller ønsker innsyn knyttet til opplysninger du har sendt inn, kan du bruke kontaktsiden eller sende e-post til <a href="mailto:teeoff@teeoff.no">teeoff@teeoff.no</a>.</p>
|
|
</section>
|
|
""".strip(),
|
|
"meta_title": "Personvern og cookies",
|
|
"meta_description": (
|
|
"Hvordan TeeOff behandler personopplysninger, bruker cookies og måleverktøy som Matomo."
|
|
),
|
|
},
|
|
"kontakt": {
|
|
"path": "/kontakt",
|
|
"eyebrow": "Kontakt",
|
|
"title": "Kontakt TeeOff",
|
|
"hero_image_url": "",
|
|
"intro_html": (
|
|
"<p>Bruk skjemaet hvis du vil melde fra om feil i klubbdata, tips om artikler, "
|
|
"spørsmål om administrasjonstilgang eller andre henvendelser.</p>"
|
|
),
|
|
"body_html": (
|
|
"<p>Du kan bruke skjemaet under hvis du vil melde fra om feil i klubbdata, "
|
|
"sende redaksjonelle tips eller spørre om klubbkontoer og administrasjonstilgang.</p>"
|
|
),
|
|
"meta_title": "Kontakt TeeOff",
|
|
"meta_description": (
|
|
"Kontakt TeeOff for feil i klubbdata, redaksjonelle tips, klubbkontoer og andre henvendelser."
|
|
),
|
|
},
|
|
}
|
|
|
|
VALID_SITE_PAGE_KEYS = set(SITE_PAGE_CONFIGS.keys())
|
|
|
|
|
|
def load_legacy_site_page_article(slug: str) -> dict[str, Any] | None:
|
|
normalized_slug = str(slug or "").strip()
|
|
if not normalized_slug:
|
|
return None
|
|
|
|
candidate_paths = [
|
|
Path(__file__).resolve().parent.parent / "frontend" / "src" / "content" / "importedMeninger.json",
|
|
Path.cwd() / "frontend" / "src" / "content" / "importedMeninger.json",
|
|
]
|
|
|
|
for candidate_path in candidate_paths:
|
|
try:
|
|
if not candidate_path.exists():
|
|
continue
|
|
payload = json.loads(candidate_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
if not isinstance(payload, list):
|
|
continue
|
|
|
|
for entry in payload:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if str(entry.get("slug") or "").strip() == normalized_slug:
|
|
return entry
|
|
|
|
return None
|
|
|
|
|
|
def build_default_site_page_row(page_key: str) -> dict[str, Any] | None:
|
|
normalized_key = str(page_key or "").strip().lower()
|
|
config = SITE_PAGE_CONFIGS.get(normalized_key)
|
|
if not config:
|
|
return None
|
|
|
|
payload = {
|
|
"page_key": normalized_key,
|
|
"eyebrow": str(config.get("eyebrow") or ""),
|
|
"title": str(config.get("title") or ""),
|
|
"hero_image_url": str(config.get("hero_image_url") or ""),
|
|
"intro_html": str(config.get("intro_html") or ""),
|
|
"body_html": str(config.get("body_html") or ""),
|
|
"meta_title": str(config.get("meta_title") or ""),
|
|
"meta_description": str(config.get("meta_description") or ""),
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
legacy_slug = LEGACY_SITE_PAGE_ARTICLE_SLUGS.get(normalized_key)
|
|
if legacy_slug:
|
|
legacy_article = load_legacy_site_page_article(legacy_slug)
|
|
if legacy_article:
|
|
payload["title"] = str(legacy_article.get("title") or payload["title"])
|
|
payload["body_html"] = str(legacy_article.get("contentHtml") or payload["body_html"])
|
|
featured_image = legacy_article.get("featuredImage") if isinstance(legacy_article, dict) else None
|
|
if isinstance(featured_image, dict):
|
|
payload["hero_image_url"] = str(
|
|
featured_image.get("originalUrl")
|
|
or featured_image.get("url")
|
|
or payload["hero_image_url"]
|
|
)
|
|
|
|
return payload
|
|
|
|
|
|
def format_site_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["page_key"] = str(d.get("page_key") or "")
|
|
d["eyebrow"] = str(d.get("eyebrow") or "")
|
|
d["title"] = str(d.get("title") or "")
|
|
d["hero_image_url"] = str(d.get("hero_image_url") or "")
|
|
d["intro_html"] = str(d.get("intro_html") or "")
|
|
d["body_html"] = str(d.get("body_html") or "")
|
|
d["meta_title"] = str(d.get("meta_title") or "")
|
|
d["meta_description"] = str(d.get("meta_description") or "")
|
|
return d
|
|
|
|
|
|
def format_simulator_operator_row(row):
|
|
if row is None:
|
|
return None
|
|
|
|
data = dict(row)
|
|
for key in ["created_at", "updated_at"]:
|
|
if isinstance(data.get(key), (date, datetime)):
|
|
data[key] = data[key].isoformat()
|
|
|
|
return data
|
|
|
|
|
|
def format_simulator_venue_row(row):
|
|
if row is None:
|
|
return None
|
|
|
|
data = dict(row)
|
|
for key in ["created_at", "updated_at"]:
|
|
if isinstance(data.get(key), (date, datetime)):
|
|
data[key] = data[key].isoformat()
|
|
|
|
simulator_systems = data.get("simulator_systems")
|
|
if simulator_systems is None:
|
|
data["simulator_systems"] = []
|
|
elif isinstance(simulator_systems, str):
|
|
try:
|
|
parsed = json.loads(simulator_systems)
|
|
data["simulator_systems"] = parsed if isinstance(parsed, list) else []
|
|
except Exception:
|
|
data["simulator_systems"] = []
|
|
elif not isinstance(simulator_systems, list):
|
|
data["simulator_systems"] = []
|
|
|
|
return data
|
|
|
|
|
|
def normalize_optional_text(value: Any) -> str | None:
|
|
normalized = str(value or "").strip()
|
|
return normalized or None
|
|
|
|
|
|
def normalize_simulator_systems(values: Any) -> list[str]:
|
|
if not isinstance(values, list):
|
|
return []
|
|
|
|
normalized: list[str] = []
|
|
seen: set[str] = set()
|
|
for value in values:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
continue
|
|
key = text.lower()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
normalized.append(text)
|
|
|
|
return normalized
|
|
|
|
|
|
def normalize_simulator_venue_type(value: str | None) -> str:
|
|
normalized = str(value or "").strip().lower()
|
|
if normalized not in SIMULATOR_VENUE_TYPES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Ugyldig simulatorsted-type.",
|
|
)
|
|
return normalized
|
|
|
|
|
|
async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest, operator_id: int | None = None):
|
|
name = str(request.name or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Operatørnavn mangler.")
|
|
|
|
slug = normalize_facility_slug(request.slug or name)
|
|
if not slug:
|
|
raise HTTPException(status_code=400, detail="Slug mangler eller er ugyldig.")
|
|
|
|
existing = await conn.fetchval(
|
|
"""
|
|
SELECT id
|
|
FROM simulator_operators
|
|
WHERE slug = $1
|
|
AND ($2::int IS NULL OR id <> $2)
|
|
""",
|
|
slug,
|
|
operator_id,
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="Slug er allerede i bruk.")
|
|
|
|
if operator_id is None:
|
|
return await conn.fetchrow(
|
|
"""
|
|
INSERT INTO simulator_operators (
|
|
name, slug, website_url, logo_url, description, meta_title, meta_description, is_published
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *
|
|
""",
|
|
name,
|
|
slug,
|
|
normalize_optional_text(request.website_url),
|
|
normalize_optional_text(request.logo_url),
|
|
normalize_optional_text(request.description),
|
|
normalize_optional_text(request.meta_title),
|
|
normalize_optional_text(request.meta_description),
|
|
bool(request.is_published),
|
|
)
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
UPDATE simulator_operators
|
|
SET name = $1,
|
|
slug = $2,
|
|
website_url = $3,
|
|
logo_url = $4,
|
|
description = $5,
|
|
meta_title = $6,
|
|
meta_description = $7,
|
|
is_published = $8,
|
|
updated_at = NOW()
|
|
WHERE id = $9
|
|
RETURNING *
|
|
""",
|
|
name,
|
|
slug,
|
|
normalize_optional_text(request.website_url),
|
|
normalize_optional_text(request.logo_url),
|
|
normalize_optional_text(request.description),
|
|
normalize_optional_text(request.meta_title),
|
|
normalize_optional_text(request.meta_description),
|
|
bool(request.is_published),
|
|
operator_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Simulatoroperatøren ble ikke funnet.")
|
|
return row
|
|
|
|
|
|
async def save_simulator_venue(conn, request: SimulatorVenueUpsertRequest, venue_id: int | None = None):
|
|
name = str(request.name or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Navn på simulatorsted mangler.")
|
|
|
|
slug = normalize_facility_slug(request.slug or name)
|
|
if not slug:
|
|
raise HTTPException(status_code=400, detail="Slug mangler eller er ugyldig.")
|
|
|
|
venue_type = normalize_simulator_venue_type(request.venue_type)
|
|
operator_id = int(request.operator_id) if request.operator_id else None
|
|
facility_id = int(request.facility_id) if request.facility_id else None
|
|
|
|
existing = await conn.fetchval(
|
|
"""
|
|
SELECT id
|
|
FROM simulator_venues
|
|
WHERE slug = $1
|
|
AND ($2::int IS NULL OR id <> $2)
|
|
""",
|
|
slug,
|
|
venue_id,
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="Slug er allerede i bruk.")
|
|
|
|
if operator_id is not None:
|
|
operator_exists = await conn.fetchval(
|
|
"SELECT id FROM simulator_operators WHERE id = $1",
|
|
operator_id,
|
|
)
|
|
if not operator_exists:
|
|
raise HTTPException(status_code=404, detail="Simulatoroperatøren ble ikke funnet.")
|
|
|
|
if facility_id is not None:
|
|
facility_exists = await conn.fetchval(
|
|
"SELECT id FROM facilities WHERE id = $1",
|
|
facility_id,
|
|
)
|
|
if not facility_exists:
|
|
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.")
|
|
|
|
query = """
|
|
{statement}
|
|
RETURNING *
|
|
"""
|
|
|
|
values = [
|
|
operator_id,
|
|
facility_id,
|
|
name,
|
|
slug,
|
|
venue_type,
|
|
normalize_optional_text(request.description),
|
|
normalize_optional_text(request.city),
|
|
normalize_optional_text(request.county),
|
|
normalize_optional_text(request.address),
|
|
normalize_optional_text(request.postal_code),
|
|
request.lat,
|
|
request.lng,
|
|
normalize_optional_text(request.website_url),
|
|
normalize_optional_text(request.booking_url),
|
|
normalize_optional_text(request.phone),
|
|
normalize_optional_text(request.email),
|
|
normalize_optional_text(request.image_url),
|
|
normalize_optional_text(request.meta_title),
|
|
normalize_optional_text(request.meta_description),
|
|
json.dumps(normalize_simulator_systems(request.simulator_systems)),
|
|
request.bay_count,
|
|
request.lessons_available,
|
|
request.club_fitting,
|
|
request.food_and_drink,
|
|
request.serves_alcohol,
|
|
request.drop_in,
|
|
request.membership_required,
|
|
normalize_optional_text(request.opening_hours),
|
|
request.price_from,
|
|
normalize_optional_text(request.season),
|
|
bool(request.is_published),
|
|
]
|
|
|
|
if venue_id is None:
|
|
statement = """
|
|
INSERT INTO simulator_venues (
|
|
operator_id, facility_id, name, slug, venue_type, description,
|
|
city, county, address, postal_code, lat, lng,
|
|
website_url, booking_url, phone, email, image_url, meta_title, meta_description,
|
|
simulator_systems, bay_count, lessons_available, club_fitting,
|
|
food_and_drink, serves_alcohol, drop_in, membership_required,
|
|
opening_hours, price_from, season, is_published
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6,
|
|
$7, $8, $9, $10, $11, $12,
|
|
$13, $14, $15, $16, $17, $18, $19, $20::jsonb,
|
|
$21, $22, $23, $24,
|
|
$25, $26, $27, $28,
|
|
$29, $30, $31
|
|
)
|
|
"""
|
|
else:
|
|
statement = """
|
|
UPDATE simulator_venues
|
|
SET operator_id = $1,
|
|
facility_id = $2,
|
|
name = $3,
|
|
slug = $4,
|
|
venue_type = $5,
|
|
description = $6,
|
|
city = $7,
|
|
county = $8,
|
|
address = $9,
|
|
postal_code = $10,
|
|
lat = $11,
|
|
lng = $12,
|
|
website_url = $13,
|
|
booking_url = $14,
|
|
phone = $15,
|
|
email = $16,
|
|
image_url = $17,
|
|
meta_title = $18,
|
|
meta_description = $19,
|
|
simulator_systems = $20::jsonb,
|
|
bay_count = $21,
|
|
lessons_available = $22,
|
|
club_fitting = $23,
|
|
food_and_drink = $24,
|
|
serves_alcohol = $25,
|
|
drop_in = $26,
|
|
membership_required = $27,
|
|
opening_hours = $28,
|
|
price_from = $29,
|
|
season = $30,
|
|
is_published = $31,
|
|
updated_at = NOW()
|
|
WHERE id = $32
|
|
"""
|
|
values.append(venue_id)
|
|
|
|
row = await conn.fetchrow(query.format(statement=statement), *values)
|
|
if venue_id is not None and not row:
|
|
raise HTTPException(status_code=404, detail="Simulatorstedet ble ikke funnet.")
|
|
return row
|
|
|
|
|
|
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
|
|
|
|
|
|
async def ensure_public_query_indexes(conn) -> None:
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS facilities_is_published_name_idx
|
|
ON facilities (is_published, name)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS courses_facility_id_idx
|
|
ON courses (facility_id)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS courses_facility_id_main_idx
|
|
ON courses (facility_id, is_main_course DESC, id ASC)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS holes_course_id_idx
|
|
ON holes (course_id)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS tees_course_sort_idx
|
|
ON tees (course_id, sort_order ASC, id ASC)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS hole_lengths_hole_idx
|
|
ON hole_lengths (hole_id)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS hole_lengths_tee_idx
|
|
ON hole_lengths (tee_id)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS facility_weather_forecast_facility_day_idx
|
|
ON facility_weather_forecast (facility_id, day_offset)
|
|
""")
|
|
|
|
|
|
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}
|
|
|
|
|
|
async def ensure_scorecard_tables(conn) -> None:
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS tees (
|
|
id SERIAL PRIMARY KEY,
|
|
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
|
name VARCHAR(50) NOT NULL,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
cr_men NUMERIC(4, 1),
|
|
slope_men INTEGER,
|
|
cr_women NUMERIC(4, 1),
|
|
slope_women INTEGER
|
|
)
|
|
""")
|
|
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0")
|
|
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS cr_men NUMERIC(4, 1)")
|
|
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS slope_men INTEGER")
|
|
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS cr_women NUMERIC(4, 1)")
|
|
await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS slope_women INTEGER")
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS hole_lengths (
|
|
id SERIAL PRIMARY KEY,
|
|
hole_id INTEGER NOT NULL REFERENCES holes(id) ON DELETE CASCADE,
|
|
tee_id INTEGER NOT NULL REFERENCES tees(id) ON DELETE CASCADE,
|
|
length_meters INTEGER
|
|
)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS hole_lengths_hole_tee_uidx
|
|
ON hole_lengths (hole_id, tee_id)
|
|
""")
|
|
|
|
course_columns = await get_table_columns(conn, "courses")
|
|
hole_columns = await get_table_columns(conn, "holes")
|
|
if "tee_boxes" not in course_columns or "lengths" not in hole_columns:
|
|
return
|
|
|
|
legacy_courses = await conn.fetch(
|
|
"""
|
|
SELECT c.id, c.tee_boxes
|
|
FROM courses c
|
|
WHERE c.tee_boxes IS NOT NULL
|
|
ORDER BY c.id ASC
|
|
"""
|
|
)
|
|
|
|
for course_row in legacy_courses:
|
|
course_id = int(course_row["id"])
|
|
tee_boxes = coerce_json_dict(course_row["tee_boxes"])
|
|
holes = [
|
|
{
|
|
"id": int(hole_row["id"]),
|
|
"lengths": coerce_json_dict(hole_row["lengths"]),
|
|
}
|
|
for hole_row in await conn.fetch(
|
|
"SELECT id, lengths FROM holes WHERE course_id = $1 ORDER BY hole_number ASC, id ASC",
|
|
course_id,
|
|
)
|
|
]
|
|
|
|
legacy_keys = resolve_legacy_course_keys(tee_boxes, holes)
|
|
if not legacy_keys:
|
|
continue
|
|
|
|
existing_tee_rows = await conn.fetch(
|
|
"""
|
|
SELECT id, name, cr_men, slope_men, cr_women, slope_women
|
|
FROM tees
|
|
WHERE course_id = $1
|
|
ORDER BY sort_order ASC, id ASC
|
|
""",
|
|
course_id,
|
|
)
|
|
|
|
compact_length = max(len(men_entries := coerce_json_list(tee_boxes.get("herrer"))), len(women_entries := coerce_json_list(tee_boxes.get("damer"))))
|
|
fallback_names = {key.replace("_", " ").title().lower() for key in LEGACY_TEE_KEYS}
|
|
has_suspicious_fallback_tees = any(
|
|
str(tee_row["name"] or "").strip().lower() in fallback_names
|
|
and tee_row["cr_men"] is None
|
|
and tee_row["slope_men"] is None
|
|
and tee_row["cr_women"] is None
|
|
and tee_row["slope_women"] is None
|
|
for tee_row in existing_tee_rows
|
|
)
|
|
should_repair_existing_tees = (
|
|
bool(existing_tee_rows)
|
|
and 0 < compact_length < len(LEGACY_TEE_KEYS)
|
|
and len(existing_tee_rows) != len(legacy_keys)
|
|
and has_suspicious_fallback_tees
|
|
)
|
|
|
|
if existing_tee_rows and not should_repair_existing_tees:
|
|
continue
|
|
|
|
if should_repair_existing_tees:
|
|
await conn.execute("DELETE FROM tees WHERE course_id = $1", course_id)
|
|
|
|
created_tee_ids: dict[str, int] = {}
|
|
|
|
for sort_order, key in enumerate(legacy_keys):
|
|
men_entry = extract_legacy_tee_entry(men_entries, key, legacy_keys)
|
|
women_entry = extract_legacy_tee_entry(women_entries, key, legacy_keys)
|
|
|
|
tee_id = await conn.fetchval(
|
|
"""
|
|
INSERT INTO tees (
|
|
course_id, name, sort_order, cr_men, slope_men, cr_women, slope_women
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id
|
|
""",
|
|
course_id,
|
|
build_legacy_tee_name(men_entry, women_entry, key, sort_order),
|
|
sort_order,
|
|
parse_optional_float(men_entry.get("baneverdi")),
|
|
parse_optional_int(men_entry.get("slopeverdi")),
|
|
parse_optional_float(women_entry.get("baneverdi_damer")),
|
|
parse_optional_int(women_entry.get("slopeverdi_damer")),
|
|
)
|
|
created_tee_ids[key] = int(tee_id)
|
|
|
|
if not created_tee_ids:
|
|
continue
|
|
|
|
for hole in holes:
|
|
length_map = coerce_json_dict(hole.get("lengths"))
|
|
for key, tee_id in created_tee_ids.items():
|
|
length_meters = parse_optional_int(length_map.get(key))
|
|
if length_meters is None:
|
|
continue
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO hole_lengths (hole_id, tee_id, length_meters)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (hole_id, tee_id)
|
|
DO UPDATE SET length_meters = EXCLUDED.length_meters
|
|
""",
|
|
int(hole["id"]),
|
|
tee_id,
|
|
length_meters,
|
|
)
|
|
|
|
|
|
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")
|
|
|
|
|
|
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"
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
def parse_optional_datetime(value: str | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
trimmed = str(value).strip()
|
|
if not trimmed:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(trimmed.replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=f"Ugyldig datoformat: {value}") from exc
|
|
|
|
|
|
def 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
|
|
|
|
|
|
def parse_optional_float(value: Any) -> float | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool):
|
|
return float(int(value))
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
|
|
trimmed = str(value).strip()
|
|
if not trimmed:
|
|
return None
|
|
|
|
try:
|
|
return float(trimmed.replace(",", "."))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def coerce_json_list(value: Any) -> list[Any]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
return value
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
return parsed if isinstance(parsed, list) else []
|
|
return []
|
|
|
|
|
|
def coerce_json_dict(value: Any) -> dict[str, Any]:
|
|
if value is None:
|
|
return {}
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
return {}
|
|
|
|
|
|
def build_submitted_tee_key(tee: dict[str, Any], fallback_index: int) -> str:
|
|
parsed_id = parse_optional_int(tee.get("id"))
|
|
if parsed_id is not None:
|
|
return str(parsed_id)
|
|
|
|
for field_name in ("client_key", "_clientId", "temp_id"):
|
|
text = str(tee.get(field_name) or "").strip()
|
|
if text:
|
|
return text
|
|
|
|
return f"new-tee-{fallback_index}"
|
|
|
|
|
|
def normalize_tee_name(value: Any, fallback_index: int) -> str:
|
|
normalized = str(value or "").strip()
|
|
return normalized or f"Utslag {fallback_index + 1}"
|
|
|
|
|
|
def resolve_compact_legacy_keys(active_keys: list[str], compact_length: int) -> list[str]:
|
|
if compact_length <= 0:
|
|
return []
|
|
|
|
if active_keys:
|
|
compact_active_keys = [key for key in active_keys if key in LEGACY_TEE_KEYS][:compact_length]
|
|
if len(compact_active_keys) == compact_length:
|
|
return compact_active_keys
|
|
|
|
return LEGACY_TEE_KEYS[:compact_length]
|
|
|
|
|
|
def extract_legacy_tee_entry(entries: list[Any], key: str, active_keys: list[str]) -> dict[str, Any]:
|
|
normalized_entries = [entry if isinstance(entry, dict) else {} for entry in (entries or [])]
|
|
if not normalized_entries:
|
|
return {}
|
|
|
|
compact_length = len(normalized_entries)
|
|
if 0 < compact_length < len(LEGACY_TEE_KEYS):
|
|
compact_keys = resolve_compact_legacy_keys(active_keys, compact_length)
|
|
compact_index = compact_keys.index(key) if key in compact_keys else -1
|
|
if compact_index >= 0 and compact_index < compact_length:
|
|
return dict(normalized_entries[compact_index] or {})
|
|
return {}
|
|
|
|
key_index = LEGACY_TEE_KEYS.index(key)
|
|
if key_index < compact_length:
|
|
return dict(normalized_entries[key_index] or {})
|
|
|
|
return {}
|
|
|
|
|
|
def legacy_tee_entry_has_content(entry: dict[str, Any]) -> bool:
|
|
return any(
|
|
str(entry.get(field_name) or "").strip()
|
|
for field_name in (
|
|
"navn_utslag",
|
|
"baneverdi",
|
|
"slopeverdi",
|
|
"navn_utslag_damer",
|
|
"baneverdi_damer",
|
|
"slopeverdi_damer",
|
|
)
|
|
)
|
|
|
|
|
|
def resolve_legacy_course_keys(tee_boxes: dict[str, Any], holes: list[dict[str, Any]]) -> list[str]:
|
|
active_keys = [
|
|
key
|
|
for key in LEGACY_TEE_KEYS
|
|
if any(parse_optional_int(coerce_json_dict(hole.get("lengths")).get(key)) is not None for hole in holes)
|
|
]
|
|
|
|
men_entries = coerce_json_list(tee_boxes.get("herrer"))
|
|
women_entries = coerce_json_list(tee_boxes.get("damer"))
|
|
compact_length = max(len(men_entries), len(women_entries))
|
|
compact_keys = (
|
|
resolve_compact_legacy_keys(active_keys, compact_length)
|
|
if 0 < compact_length < len(LEGACY_TEE_KEYS)
|
|
else []
|
|
)
|
|
|
|
if compact_keys:
|
|
candidate_keys = [key for key in compact_keys if key in active_keys]
|
|
keys_to_inspect = list(compact_keys)
|
|
else:
|
|
candidate_keys = list(active_keys) if active_keys else []
|
|
keys_to_inspect = list(LEGACY_TEE_KEYS)
|
|
|
|
for key in keys_to_inspect:
|
|
men_entry = extract_legacy_tee_entry(men_entries, key, active_keys)
|
|
women_entry = extract_legacy_tee_entry(women_entries, key, active_keys)
|
|
if legacy_tee_entry_has_content(men_entry) or legacy_tee_entry_has_content(women_entry):
|
|
if key not in candidate_keys:
|
|
candidate_keys.append(key)
|
|
|
|
if candidate_keys:
|
|
return [key for key in LEGACY_TEE_KEYS if key in candidate_keys]
|
|
|
|
if compact_keys:
|
|
return compact_keys
|
|
|
|
return []
|
|
|
|
|
|
def build_legacy_tee_name(men_entry: dict[str, Any], women_entry: dict[str, Any], key: str, fallback_index: int) -> str:
|
|
for candidate in (
|
|
men_entry.get("navn_utslag"),
|
|
women_entry.get("navn_utslag_damer"),
|
|
women_entry.get("navn_utslag"),
|
|
men_entry.get("navn_utslag_damer"),
|
|
):
|
|
normalized = str(candidate or "").strip()
|
|
if normalized:
|
|
return normalized
|
|
|
|
return normalize_tee_name(key.replace("_", " ").title(), fallback_index)
|
|
|
|
|
|
def coerce_length_mapping(hole: dict[str, Any]) -> dict[str, Any]:
|
|
if isinstance(hole.get("lengths_by_tee"), dict):
|
|
return dict(hole.get("lengths_by_tee") or {})
|
|
return coerce_json_dict(hole.get("lengths"))
|
|
|
|
|
|
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),
|
|
"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
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
def format_course_payload_row(
|
|
course_row: Any,
|
|
tees_by_course_id: dict[int, list[dict[str, Any]]],
|
|
holes_by_course_id: dict[int, list[dict[str, Any]]],
|
|
length_rows_by_hole_id: dict[int, list[dict[str, Any]]],
|
|
) -> dict[str, Any]:
|
|
data = dict(course_row)
|
|
course_id = int(data["id"])
|
|
|
|
if isinstance(data.get("slope_valid_until"), (date, datetime)):
|
|
data["slope_valid_until"] = data["slope_valid_until"].isoformat()
|
|
|
|
data.pop("tee_boxes", None)
|
|
data["tees"] = tees_by_course_id.get(course_id, [])
|
|
|
|
holes_payload: list[dict[str, Any]] = []
|
|
for hole_row in holes_by_course_id.get(course_id, []):
|
|
hole_data = dict(hole_row)
|
|
hole_id = int(hole_data["id"])
|
|
hole_data.pop("lengths", None)
|
|
hole_data["lengths_by_tee"] = {
|
|
str(length_row["tee_id"]): int(length_row["length_meters"])
|
|
for length_row in length_rows_by_hole_id.get(hole_id, [])
|
|
if length_row.get("length_meters") is not None
|
|
}
|
|
holes_payload.append(hole_data)
|
|
|
|
data["holes"] = holes_payload
|
|
return data
|
|
|
|
|
|
async def build_facility_course_payloads(
|
|
conn,
|
|
facility_id: int,
|
|
*,
|
|
include_unpublished_courses: bool,
|
|
) -> list[dict[str, Any]]:
|
|
if include_unpublished_courses:
|
|
course_rows = await conn.fetch(
|
|
"""
|
|
SELECT *
|
|
FROM courses
|
|
WHERE facility_id = $1
|
|
ORDER BY is_main_course DESC, id ASC
|
|
""",
|
|
facility_id,
|
|
)
|
|
else:
|
|
course_rows = await conn.fetch(
|
|
"""
|
|
SELECT *
|
|
FROM courses
|
|
WHERE facility_id = $1
|
|
AND (is_main_course = TRUE OR (status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
|
ORDER BY is_main_course DESC, id ASC
|
|
""",
|
|
facility_id,
|
|
)
|
|
|
|
if not course_rows:
|
|
return []
|
|
|
|
course_ids = [int(row["id"]) for row in course_rows]
|
|
tee_rows = await conn.fetch(
|
|
"""
|
|
SELECT *
|
|
FROM tees
|
|
WHERE course_id = ANY($1::int[])
|
|
ORDER BY course_id ASC, sort_order ASC, id ASC
|
|
""",
|
|
course_ids,
|
|
)
|
|
hole_rows = await conn.fetch(
|
|
"""
|
|
SELECT *
|
|
FROM holes
|
|
WHERE course_id = ANY($1::int[])
|
|
ORDER BY course_id ASC, hole_number ASC, id ASC
|
|
""",
|
|
course_ids,
|
|
)
|
|
|
|
hole_ids = [int(row["id"]) for row in hole_rows]
|
|
length_rows = await conn.fetch(
|
|
"""
|
|
SELECT hl.hole_id, hl.tee_id, hl.length_meters
|
|
FROM hole_lengths hl
|
|
JOIN tees t ON t.id = hl.tee_id
|
|
WHERE hl.hole_id = ANY($1::int[])
|
|
AND t.course_id = ANY($2::int[])
|
|
ORDER BY hl.hole_id ASC, t.sort_order ASC, hl.tee_id ASC
|
|
""",
|
|
hole_ids or [0],
|
|
course_ids,
|
|
)
|
|
|
|
tees_by_course_id: dict[int, list[dict[str, Any]]] = {}
|
|
for tee_row in tee_rows:
|
|
tee_data = dict(tee_row)
|
|
tee_id = int(tee_data["id"])
|
|
course_id = int(tee_data["course_id"])
|
|
if tee_data.get("cr_men") is not None:
|
|
tee_data["cr_men"] = float(tee_data["cr_men"])
|
|
if tee_data.get("cr_women") is not None:
|
|
tee_data["cr_women"] = float(tee_data["cr_women"])
|
|
tee_data["client_key"] = str(tee_id)
|
|
tees_by_course_id.setdefault(course_id, []).append(tee_data)
|
|
|
|
holes_by_course_id: dict[int, list[dict[str, Any]]] = {}
|
|
for hole_row in hole_rows:
|
|
course_id = int(hole_row["course_id"])
|
|
holes_by_course_id.setdefault(course_id, []).append(dict(hole_row))
|
|
|
|
length_rows_by_hole_id: dict[int, list[dict[str, Any]]] = {}
|
|
for length_row in length_rows:
|
|
hole_id = int(length_row["hole_id"])
|
|
length_rows_by_hole_id.setdefault(hole_id, []).append(dict(length_row))
|
|
|
|
return [
|
|
format_course_payload_row(course_row, tees_by_course_id, holes_by_course_id, length_rows_by_hole_id)
|
|
for course_row in course_rows
|
|
]
|
|
|
|
|
|
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")
|
|
course_columns = await get_table_columns(conn, "courses")
|
|
hole_columns = await get_table_columns(conn, "holes")
|
|
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 = parse_optional_int(course.get('id'))
|
|
holes = [hole for hole in (course.get('holes') or []) if hole]
|
|
tees = [tee for tee in (course.get('tees') or []) if tee]
|
|
hole_count = len(holes) or None
|
|
course_par = parse_optional_int(course.get('par'))
|
|
submitted_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
|
|
|
|
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, slope_valid_until=$8
|
|
WHERE id=$9 AND facility_id=$10
|
|
""",
|
|
course.get('name'), hole_count, course_par, submitted_course_length_meters,
|
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
|
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, slope_valid_until
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id
|
|
""",
|
|
facility_id, course.get('name'), hole_count, course_par, submitted_course_length_meters,
|
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
|
valid_until)
|
|
|
|
retained_course_ids.append(int(course_id))
|
|
|
|
retained_tee_ids: list[int] = []
|
|
submitted_tee_key_to_db_id: dict[str, int] = {}
|
|
|
|
for tee_index, tee in enumerate(tees):
|
|
tee_data = dict(tee)
|
|
tee_id = parse_optional_int(tee_data.get("id"))
|
|
submitted_key = build_submitted_tee_key(tee_data, tee_index)
|
|
tee_name = normalize_tee_name(tee_data.get("name"), tee_index)
|
|
tee_sort_order = parse_optional_int(tee_data.get("sort_order"))
|
|
if tee_sort_order is None:
|
|
tee_sort_order = tee_index
|
|
|
|
if tee_id:
|
|
await conn.execute(
|
|
"""
|
|
UPDATE tees
|
|
SET name = $1,
|
|
sort_order = $2,
|
|
cr_men = $3,
|
|
slope_men = $4,
|
|
cr_women = $5,
|
|
slope_women = $6
|
|
WHERE id = $7 AND course_id = $8
|
|
""",
|
|
tee_name,
|
|
tee_sort_order,
|
|
parse_optional_float(tee_data.get("cr_men")),
|
|
parse_optional_int(tee_data.get("slope_men")),
|
|
parse_optional_float(tee_data.get("cr_women")),
|
|
parse_optional_int(tee_data.get("slope_women")),
|
|
tee_id,
|
|
course_id,
|
|
)
|
|
persisted_tee_id = tee_id
|
|
else:
|
|
persisted_tee_id = await conn.fetchval(
|
|
"""
|
|
INSERT INTO tees (
|
|
course_id, name, sort_order, cr_men, slope_men, cr_women, slope_women
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id
|
|
""",
|
|
course_id,
|
|
tee_name,
|
|
tee_sort_order,
|
|
parse_optional_float(tee_data.get("cr_men")),
|
|
parse_optional_int(tee_data.get("slope_men")),
|
|
parse_optional_float(tee_data.get("cr_women")),
|
|
parse_optional_int(tee_data.get("slope_women")),
|
|
)
|
|
|
|
retained_tee_ids.append(int(persisted_tee_id))
|
|
submitted_tee_key_to_db_id[submitted_key] = int(persisted_tee_id)
|
|
submitted_tee_key_to_db_id[str(int(persisted_tee_id))] = int(persisted_tee_id)
|
|
|
|
retained_hole_ids: list[int] = []
|
|
course_length_totals: dict[int, int] = {}
|
|
for hole in holes:
|
|
hole_id = parse_optional_int(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'))
|
|
if hole_id:
|
|
await conn.execute("""
|
|
UPDATE holes
|
|
SET hole_number=$1, par=$2, hcp_index=$3
|
|
WHERE id=$4 AND course_id=$5
|
|
""",
|
|
hole_number, hole_par, hole_hcp_index, hole_id, course_id)
|
|
else:
|
|
hole_id = await conn.fetchval("""
|
|
INSERT INTO holes (course_id, hole_number, par, hcp_index)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
""",
|
|
course_id, hole_number, hole_par, hole_hcp_index)
|
|
|
|
retained_hole_ids.append(int(hole_id))
|
|
|
|
await conn.execute("DELETE FROM hole_lengths WHERE hole_id = $1", hole_id)
|
|
for submitted_tee_key, raw_length in coerce_length_mapping(dict(hole)).items():
|
|
normalized_submitted_key = str(submitted_tee_key or "").strip()
|
|
if not normalized_submitted_key:
|
|
continue
|
|
|
|
tee_id = submitted_tee_key_to_db_id.get(normalized_submitted_key)
|
|
if tee_id is None:
|
|
parsed_submitted_tee_id = parse_optional_int(normalized_submitted_key)
|
|
if parsed_submitted_tee_id is not None and parsed_submitted_tee_id in retained_tee_ids:
|
|
tee_id = parsed_submitted_tee_id
|
|
if tee_id is None:
|
|
continue
|
|
|
|
length_meters = parse_optional_int(raw_length)
|
|
if length_meters is None:
|
|
continue
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO hole_lengths (hole_id, tee_id, length_meters)
|
|
VALUES ($1, $2, $3)
|
|
""",
|
|
hole_id,
|
|
tee_id,
|
|
length_meters,
|
|
)
|
|
course_length_totals[tee_id] = course_length_totals.get(tee_id, 0) + length_meters
|
|
|
|
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_tee_ids:
|
|
await conn.execute(
|
|
"DELETE FROM tees WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
|
|
course_id,
|
|
retained_tee_ids,
|
|
)
|
|
else:
|
|
await conn.execute("DELETE FROM tees WHERE course_id = $1", course_id)
|
|
|
|
if submitted_course_length_meters is None and course_length_totals:
|
|
await conn.execute(
|
|
"UPDATE courses SET length_meters = $1 WHERE id = $2",
|
|
max(course_length_totals.values()),
|
|
course_id,
|
|
)
|
|
|
|
if "tee_boxes" in course_columns:
|
|
await conn.execute("UPDATE courses SET tee_boxes = NULL WHERE id = $1", course_id)
|
|
|
|
if "lengths" in hole_columns and retained_hole_ids:
|
|
await conn.execute(
|
|
"UPDATE holes SET lengths = NULL WHERE id = ANY($1::int[])",
|
|
retained_hole_ids,
|
|
)
|
|
|
|
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 is_published IS DISTINCT FROM FALSE
|
|
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,
|
|
)
|
|
|
|
|
|
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()
|
|
]
|
|
|
|
|
|
|
|
async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: str | None = None):
|
|
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.")
|
|
|
|
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]
|
|
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."
|
|
)
|
|
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."
|
|
)
|
|
return {
|
|
"status": status,
|
|
"message": message,
|
|
"job": job,
|
|
"conflicting_facility_ids": overlapping_ids,
|
|
"idle_facility_ids": available_ids,
|
|
}
|
|
|
|
|
|
async def ensure_facility_columns(conn):
|
|
"""Legger til nye facility-kolonner ved behov."""
|
|
await conn.execute("""
|
|
ALTER TABLE facilities
|
|
ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT TRUE,
|
|
ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ,
|
|
ADD COLUMN IF NOT EXISTS camper_parking TEXT,
|
|
ADD COLUMN IF NOT EXISTS meta_title TEXT,
|
|
ADD COLUMN IF NOT EXISTS meta_description TEXT,
|
|
ADD COLUMN IF NOT EXISTS golfamore_url TEXT,
|
|
ADD COLUMN IF NOT EXISTS videos JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
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"]),
|
|
)
|
|
|
|
|
|
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,
|
|
meta_title TEXT,
|
|
meta_description TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
""")
|
|
await conn.execute("ALTER TABLE place_pages ADD COLUMN IF NOT EXISTS meta_title TEXT")
|
|
await conn.execute("ALTER TABLE place_pages ADD COLUMN IF NOT EXISTS meta_description TEXT")
|
|
|
|
|
|
async def ensure_site_page_seo_table(conn):
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS site_page_seo (
|
|
page_key VARCHAR(255) PRIMARY KEY,
|
|
meta_title TEXT,
|
|
meta_description TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
""")
|
|
|
|
|
|
async def ensure_site_pages_table(conn):
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS site_pages (
|
|
page_key VARCHAR(255) PRIMARY KEY,
|
|
eyebrow TEXT,
|
|
title TEXT,
|
|
hero_image_url TEXT,
|
|
intro_html TEXT,
|
|
body_html TEXT,
|
|
meta_title TEXT,
|
|
meta_description TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
""")
|
|
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS eyebrow TEXT")
|
|
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS title TEXT")
|
|
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS hero_image_url TEXT")
|
|
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS intro_html TEXT")
|
|
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS body_html TEXT")
|
|
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS meta_title TEXT")
|
|
await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS meta_description TEXT")
|
|
|
|
for page_key in VALID_SITE_PAGE_KEYS:
|
|
default_row = build_default_site_page_row(page_key)
|
|
if not default_row:
|
|
continue
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO site_pages (
|
|
page_key,
|
|
eyebrow,
|
|
title,
|
|
hero_image_url,
|
|
intro_html,
|
|
body_html,
|
|
meta_title,
|
|
meta_description
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (page_key) DO NOTHING
|
|
""",
|
|
default_row["page_key"],
|
|
default_row["eyebrow"],
|
|
default_row["title"],
|
|
default_row["hero_image_url"],
|
|
default_row["intro_html"],
|
|
default_row["body_html"],
|
|
default_row["meta_title"],
|
|
default_row["meta_description"],
|
|
)
|
|
|
|
|
|
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',
|
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
meta_title TEXT,
|
|
meta_description TEXT,
|
|
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),
|
|
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("""
|
|
ALTER TABLE articles
|
|
ADD COLUMN IF NOT EXISTS meta_title TEXT
|
|
""")
|
|
await conn.execute("""
|
|
ALTER TABLE articles
|
|
ADD COLUMN IF NOT EXISTS meta_description TEXT
|
|
""")
|
|
await conn.execute("""
|
|
UPDATE articles
|
|
SET section = 'banebesok'
|
|
WHERE section IS NULL OR TRIM(section) = ''
|
|
""")
|
|
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)")
|
|
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)
|
|
""")
|
|
|
|
|
|
async def ensure_simulator_operator_tables(conn):
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS simulator_operators (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(255) NOT NULL,
|
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
|
website_url TEXT,
|
|
logo_url TEXT,
|
|
description TEXT,
|
|
meta_title TEXT,
|
|
meta_description TEXT,
|
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
""")
|
|
await conn.execute("ALTER TABLE simulator_operators ADD COLUMN IF NOT EXISTS meta_title TEXT")
|
|
await conn.execute("ALTER TABLE simulator_operators ADD COLUMN IF NOT EXISTS meta_description TEXT")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS simulator_operators_name_idx
|
|
ON simulator_operators (name)
|
|
""")
|
|
|
|
|
|
async def ensure_simulator_venue_tables(conn):
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS simulator_venues (
|
|
id SERIAL PRIMARY KEY,
|
|
operator_id INTEGER REFERENCES simulator_operators(id) ON DELETE SET NULL,
|
|
facility_id INTEGER REFERENCES facilities(id) ON DELETE SET NULL,
|
|
name VARCHAR(255) NOT NULL,
|
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
|
venue_type VARCHAR(64) NOT NULL,
|
|
description TEXT,
|
|
city VARCHAR(255),
|
|
county VARCHAR(255),
|
|
address TEXT,
|
|
postal_code VARCHAR(32),
|
|
lat DOUBLE PRECISION,
|
|
lng DOUBLE PRECISION,
|
|
website_url TEXT,
|
|
booking_url TEXT,
|
|
phone VARCHAR(64),
|
|
email VARCHAR(255),
|
|
image_url TEXT,
|
|
meta_title TEXT,
|
|
meta_description TEXT,
|
|
simulator_systems JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
bay_count INTEGER,
|
|
lessons_available BOOLEAN,
|
|
club_fitting BOOLEAN,
|
|
food_and_drink BOOLEAN,
|
|
serves_alcohol BOOLEAN,
|
|
drop_in BOOLEAN,
|
|
membership_required BOOLEAN,
|
|
opening_hours TEXT,
|
|
price_from DOUBLE PRECISION,
|
|
season VARCHAR(255),
|
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS simulator_venues_name_idx
|
|
ON simulator_venues (name)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS simulator_venues_operator_id_idx
|
|
ON simulator_venues (operator_id)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS simulator_venues_facility_id_idx
|
|
ON simulator_venues (facility_id)
|
|
""")
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS simulator_venues_public_location_idx
|
|
ON simulator_venues (is_published, county, city, name)
|
|
""")
|
|
await conn.execute("ALTER TABLE simulator_venues ADD COLUMN IF NOT EXISTS meta_title TEXT")
|
|
await conn.execute("ALTER TABLE simulator_venues ADD COLUMN IF NOT EXISTS meta_description TEXT")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Opprett database-pool ved start
|
|
try:
|
|
print("📡 Forsøker å koble til database")
|
|
app.state.pool = await asyncpg.create_pool(
|
|
DB_URL,
|
|
min_size=5,
|
|
max_size=20,
|
|
command_timeout=60
|
|
)
|
|
async with app.state.pool.acquire() as conn:
|
|
await ensure_facility_columns(conn)
|
|
await ensure_scorecard_tables(conn)
|
|
await ensure_vtg_course_tables(conn)
|
|
await ensure_place_pages_table(conn)
|
|
await ensure_site_page_seo_table(conn)
|
|
await ensure_site_pages_table(conn)
|
|
await ensure_articles_table(conn)
|
|
await ensure_public_user_tables(conn)
|
|
await ensure_simulator_operator_tables(conn)
|
|
await ensure_simulator_venue_tables(conn)
|
|
await ensure_scrape_jobs_table(conn)
|
|
await ensure_course_status_history_table(conn)
|
|
await ensure_weather_forecast_table(conn)
|
|
await ensure_public_query_indexes(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 = {}
|
|
initialize_public_api_caches()
|
|
print("✅ Database tilkoblet og pool opprettet")
|
|
except Exception as e:
|
|
print(f"❌ Databasefeil under oppstart: {e}")
|
|
raise e
|
|
yield
|
|
# Lukk pool ved avslutning
|
|
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
|
|
await app.state.pool.close()
|
|
|
|
app = FastAPI(title="TeeOff API v3.8.0", lifespan=lifespan)
|
|
|
|
# CORS - Tillater både lokal utvikling og produksjonsdomene
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[
|
|
"https://nye.teeoff.no",
|
|
"http://nye.teeoff.no",
|
|
"http://localhost:3000"
|
|
],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@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."""
|
|
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')
|
|
)
|
|
|
|
if not admin:
|
|
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
|
|
|
try:
|
|
is_valid = pwd_context.verify(data.get('password'), admin['password_hash'])
|
|
except Exception as e:
|
|
print("❌ Kunne ikke verifisere admin-passord")
|
|
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
|
|
|
|
if not is_valid:
|
|
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
|
|
|
temp_token = jwt.encode(
|
|
{
|
|
"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")
|
|
async def verify_2fa(data: dict, response: Response, request: Request):
|
|
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
|
|
try:
|
|
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
|
|
if not payload.get("partial"):
|
|
raise JWTError()
|
|
username = payload.get("sub")
|
|
remember_me = bool(payload.get("remember_me"))
|
|
except JWTError:
|
|
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
|
|
|
|
totp = pyotp.TOTP(admin['otp_secret'])
|
|
if not totp.verify(data.get('code')):
|
|
print("❌ Ugyldig 2FA-kode ved admin-innlogging")
|
|
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
|
|
|
|
session_max_age = (
|
|
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS
|
|
if remember_me
|
|
else ADMIN_SESSION_MAX_AGE_SECONDS
|
|
)
|
|
final_token = jwt.encode(
|
|
{"sub": username, "exp": datetime.utcnow() + timedelta(seconds=session_max_age)},
|
|
SECRET_KEY, algorithm=ALGORITHM
|
|
)
|
|
|
|
# Sett som HTTP-only cookie
|
|
response.set_cookie(
|
|
key="admin_session",
|
|
value=final_token,
|
|
max_age=session_max_age,
|
|
expires=session_max_age,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=should_use_secure_cookies(request),
|
|
)
|
|
return {"status": "success"}
|
|
|
|
@app.post("/api/auth/logout")
|
|
async def logout(response: Response, request: Request):
|
|
"""Logger ut admin ved å slette sesjonscookien."""
|
|
response.delete_cookie(
|
|
key="admin_session",
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=should_use_secure_cookies(request),
|
|
)
|
|
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.",
|
|
}
|
|
|
|
|
|
@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)
|
|
is_new_rating = False
|
|
|
|
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.")
|
|
|
|
existing_rating = await conn.fetchrow(
|
|
"""
|
|
SELECT 1
|
|
FROM facility_ratings
|
|
WHERE facility_id = $1 AND user_id = $2
|
|
LIMIT 1
|
|
""",
|
|
int(facility["id"]),
|
|
int(viewer["id"]),
|
|
)
|
|
is_new_rating = existing_rating is None
|
|
|
|
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"]),
|
|
)
|
|
|
|
try:
|
|
facility_url = f"{build_public_base_url(request)}/golfbaner/{facility['slug']}"
|
|
summary = response_payload["summary"]
|
|
overall_rating = round(
|
|
(payload.quality_rating + payload.conditions_rating + payload.hospitality_rating) / 3,
|
|
1,
|
|
)
|
|
await send_facility_rating_notification_email(
|
|
facility_name=str(facility["name"] or facility["slug"]),
|
|
facility_url=facility_url,
|
|
reviewer_name=str(viewer.get("display_name") or viewer.get("full_name") or "TeeOff-leser"),
|
|
reviewer_email=(str(viewer.get("email")).strip() if viewer.get("email") else None),
|
|
quality_rating=payload.quality_rating,
|
|
conditions_rating=payload.conditions_rating,
|
|
hospitality_rating=payload.hospitality_rating,
|
|
overall_rating=overall_rating,
|
|
rating_count=int(summary["rating_count"] or 0),
|
|
quality_average=summary["quality_average"],
|
|
conditions_average=summary["conditions_average"],
|
|
hospitality_average=summary["hospitality_average"],
|
|
overall_average=summary["overall_average"],
|
|
is_new_rating=is_new_rating,
|
|
ip_hash=hash_request_ip(request),
|
|
)
|
|
except Exception as exc:
|
|
print(f"Kunne ikke sende vurderingsvarsel: {exc}")
|
|
|
|
return {
|
|
"detail": "Vurderingen er lagret.",
|
|
"viewer": viewer,
|
|
**response_payload,
|
|
}
|
|
|
|
# --- DATA ENDPOINTS ---
|
|
|
|
def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | None]:
|
|
normalized_view = (view or "").strip().lower()
|
|
published_facilities_cte = """
|
|
published_facilities AS (
|
|
SELECT *
|
|
FROM facilities
|
|
WHERE is_published IS DISTINCT FROM FALSE
|
|
)
|
|
"""
|
|
course_statuses_cte = """
|
|
course_statuses AS (
|
|
SELECT
|
|
c.facility_id,
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'id', c.id,
|
|
'name', c.name,
|
|
'status', c.status
|
|
)
|
|
ORDER BY c.is_main_course DESC, c.id ASC
|
|
) AS course_statuses
|
|
FROM courses c
|
|
JOIN published_facilities pf ON pf.id = c.facility_id
|
|
WHERE c.status != 'finnes_ingen_bane_to'
|
|
GROUP BY c.facility_id
|
|
)
|
|
"""
|
|
weather_compact_cte = """
|
|
weather_compact AS (
|
|
SELECT
|
|
facility_id,
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'day_offset', day_offset,
|
|
'dry_daylight', dry_daylight
|
|
)
|
|
ORDER BY day_offset ASC
|
|
) AS weather_forecast
|
|
FROM facility_weather_forecast
|
|
WHERE facility_id IN (SELECT id FROM published_facilities)
|
|
GROUP BY facility_id
|
|
)
|
|
"""
|
|
weather_full_cte = """
|
|
weather_full AS (
|
|
SELECT
|
|
facility_id,
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'forecast_date', forecast_date,
|
|
'day_offset', day_offset,
|
|
'dry_all_day', dry_all_day,
|
|
'dry_daylight', dry_daylight,
|
|
'precip_mm', precip_mm,
|
|
'precip_probability_max', precip_probability_max,
|
|
'daylight_precip_mm', daylight_precip_mm,
|
|
'daylight_precip_probability_max', daylight_precip_probability_max,
|
|
'confidence', confidence,
|
|
'source_updated_at', source_updated_at,
|
|
'source_expires_at', source_expires_at,
|
|
'calculated_at', calculated_at
|
|
)
|
|
ORDER BY day_offset ASC
|
|
) AS weather_forecast
|
|
FROM facility_weather_forecast
|
|
WHERE facility_id IN (SELECT id FROM published_facilities)
|
|
GROUP BY facility_id
|
|
)
|
|
"""
|
|
hole_counts_cte = """
|
|
hole_counts AS (
|
|
SELECT
|
|
c.facility_id,
|
|
COUNT(h.id) AS total_hole_count,
|
|
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)
|
|
) AS hole_par_counts
|
|
FROM courses c
|
|
JOIN holes h ON h.course_id = c.id
|
|
JOIN published_facilities pf ON pf.id = c.facility_id
|
|
GROUP BY c.facility_id
|
|
)
|
|
"""
|
|
hole_lengths_cte = """
|
|
hole_lengths AS (
|
|
SELECT
|
|
c.facility_id,
|
|
MIN(hl.length_meters) AS shortest_hole_meters,
|
|
MAX(hl.length_meters) AS longest_hole_meters
|
|
FROM hole_lengths hl
|
|
JOIN holes h ON h.id = hl.hole_id
|
|
JOIN tees t ON t.id = hl.tee_id
|
|
JOIN courses c ON c.id = t.course_id AND c.id = h.course_id
|
|
JOIN published_facilities pf ON pf.id = c.facility_id
|
|
WHERE hl.length_meters BETWEEN 30 AND 900
|
|
GROUP BY c.facility_id
|
|
)
|
|
"""
|
|
has_golfpakker_sql = """
|
|
CASE
|
|
WHEN jsonb_typeof(pf.golfpakker) = 'array' AND jsonb_array_length(pf.golfpakker) > 0 THEN TRUE
|
|
WHEN NULLIF(BTRIM(COALESCE(pf.golfpakker_url, '')), '') IS NOT NULL THEN TRUE
|
|
ELSE FALSE
|
|
END AS has_golfpakker
|
|
"""
|
|
|
|
if normalized_view in {"search", "home"}:
|
|
return (
|
|
f"""
|
|
WITH
|
|
{published_facilities_cte},
|
|
{course_statuses_cte},
|
|
{weather_compact_cte}
|
|
SELECT
|
|
pf.id,
|
|
pf.slug,
|
|
pf.name,
|
|
pf.architect,
|
|
pf.description,
|
|
pf.city,
|
|
pf.county,
|
|
pf.banetype,
|
|
pf.image_url,
|
|
pf.phone,
|
|
pf.website_url,
|
|
pf.golfbox_booking_url,
|
|
pf.golfbox_tournament_url,
|
|
pf.weather_url,
|
|
pf.lat,
|
|
pf.lng,
|
|
pf.golfamore,
|
|
pf.golfamore_url,
|
|
pf.nsg_url,
|
|
{has_golfpakker_sql},
|
|
pf.vtg_pris,
|
|
pf.vtg_lenke,
|
|
pf.vtg_beskrivelse,
|
|
pf.camper_parking,
|
|
pf.meta_title,
|
|
pf.meta_description,
|
|
pf.amenities,
|
|
pf.golfamore_data,
|
|
pf.nsg_data,
|
|
pf.vtg_datoer,
|
|
pf.footnote,
|
|
pf.footnote_updated_at,
|
|
pf.status_updated_at,
|
|
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
|
COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
|
|
FROM published_facilities pf
|
|
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
|
LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
|
|
ORDER BY pf.name ASC
|
|
""",
|
|
FACILITY_VIEW_SEARCH_FIELDS,
|
|
)
|
|
|
|
if normalized_view == "place":
|
|
return (
|
|
f"""
|
|
WITH
|
|
{published_facilities_cte},
|
|
{course_statuses_cte},
|
|
{weather_compact_cte},
|
|
{hole_counts_cte},
|
|
{hole_lengths_cte}
|
|
SELECT
|
|
pf.id,
|
|
pf.slug,
|
|
pf.name,
|
|
pf.architect,
|
|
pf.description,
|
|
pf.city,
|
|
pf.county,
|
|
pf.banetype,
|
|
pf.image_url,
|
|
pf.phone,
|
|
pf.website_url,
|
|
pf.golfbox_booking_url,
|
|
pf.golfbox_tournament_url,
|
|
pf.weather_url,
|
|
pf.lat,
|
|
pf.lng,
|
|
pf.golfamore,
|
|
pf.golfamore_url,
|
|
pf.nsg_url,
|
|
{has_golfpakker_sql},
|
|
pf.greenfee,
|
|
pf.standard_medlemskap,
|
|
pf.vtg_pris,
|
|
pf.vtg_lenke,
|
|
pf.vtg_beskrivelse,
|
|
pf.camper_parking,
|
|
pf.meta_title,
|
|
pf.meta_description,
|
|
pf.amenities,
|
|
pf.golfamore_data,
|
|
pf.nsg_data,
|
|
pf.vtg_datoer,
|
|
pf.footnote,
|
|
pf.footnote_updated_at,
|
|
pf.status_updated_at,
|
|
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
|
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
|
|
COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
|
|
hl.shortest_hole_meters,
|
|
hl.longest_hole_meters,
|
|
COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
|
|
FROM published_facilities pf
|
|
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
|
LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
|
|
LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
|
|
LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id
|
|
ORDER BY pf.name ASC
|
|
""",
|
|
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
|
|
FROM facilities f
|
|
WHERE f.is_published IS DISTINCT FROM FALSE
|
|
ORDER BY f.name ASC
|
|
""",
|
|
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 f.is_published IS DISTINCT FROM FALSE
|
|
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 f.is_published IS DISTINCT FROM FALSE
|
|
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 f.is_published IS DISTINCT FROM FALSE
|
|
ORDER BY f.name ASC
|
|
""",
|
|
FACILITY_VIEW_SITEMAP_FIELDS,
|
|
)
|
|
|
|
if normalized_view == "aliases":
|
|
return (
|
|
"""
|
|
SELECT
|
|
f.slug,
|
|
f.name
|
|
FROM facilities f
|
|
WHERE f.is_published IS DISTINCT FROM FALSE
|
|
ORDER BY f.name ASC
|
|
""",
|
|
FACILITY_VIEW_ALIASES_FIELDS,
|
|
)
|
|
|
|
return (
|
|
f"""
|
|
WITH
|
|
{published_facilities_cte},
|
|
{course_statuses_cte},
|
|
{weather_full_cte},
|
|
{hole_counts_cte},
|
|
{hole_lengths_cte}
|
|
SELECT
|
|
pf.*,
|
|
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
|
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
|
|
COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
|
|
hl.shortest_hole_meters,
|
|
hl.longest_hole_meters,
|
|
COALESCE(wf.weather_forecast, '[]'::jsonb) AS weather_forecast
|
|
FROM published_facilities pf
|
|
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
|
LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
|
|
LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id
|
|
LEFT JOIN weather_full wf ON wf.facility_id = pf.id
|
|
ORDER BY pf.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
|
|
|
|
@app.get("/api/facilities/{slug}")
|
|
async def get_facility(slug: str, response: Response):
|
|
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
|
|
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
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow("""
|
|
SELECT f.*, (
|
|
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
|
|
AND f.is_published IS DISTINCT FROM FALSE
|
|
""", slug)
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
|
|
|
payload = sanitize_public_facility_row(row)
|
|
payload["courses"] = await build_facility_course_payloads(
|
|
conn,
|
|
int(row["id"]),
|
|
include_unpublished_courses=False,
|
|
)
|
|
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
|
|
|
|
|
|
@app.get("/api/place-pages/{slug}")
|
|
async def get_place_page(slug: str, response: Response):
|
|
normalized_slug = str(slug or "").strip().lower()
|
|
if not normalized_slug:
|
|
raise HTTPException(status_code=400, detail="Slug mangler.")
|
|
|
|
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
|
|
|
|
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": "",
|
|
"meta_title": "",
|
|
"meta_description": "",
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
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
|
|
|
|
|
|
@app.get("/api/site-pages/{page_key}")
|
|
async def get_public_site_page(page_key: str, response: Response):
|
|
normalized_key = str(page_key or "").strip().lower()
|
|
if normalized_key not in VALID_SITE_PAGE_KEYS:
|
|
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
|
|
|
site_page_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_site_page_cache", {})
|
|
cached_payload = read_public_cache_entry(site_page_cache, normalized_key)
|
|
if cached_payload is not None:
|
|
apply_public_cache_headers(response, PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS)
|
|
return cached_payload
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT * FROM site_pages WHERE page_key = $1",
|
|
normalized_key,
|
|
)
|
|
|
|
payload = format_site_page_row(row) if row else build_default_site_page_row(normalized_key)
|
|
if payload is None:
|
|
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
|
|
|
write_public_cache_entry(
|
|
site_page_cache,
|
|
normalized_key,
|
|
payload,
|
|
PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS,
|
|
)
|
|
apply_public_cache_headers(response, PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS)
|
|
return payload
|
|
|
|
|
|
VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "banebesok", "meninger", "simulatorer"}
|
|
|
|
|
|
@app.get("/api/page-seo/{page_key}")
|
|
async def get_public_site_page_seo(page_key: str):
|
|
normalized_key = str(page_key or "").strip().lower()
|
|
if normalized_key not in VALID_SITE_PAGE_SEO_KEYS:
|
|
raise HTTPException(status_code=404, detail="SEO-side ikke funnet.")
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT * FROM site_page_seo WHERE page_key = $1",
|
|
normalized_key,
|
|
)
|
|
|
|
if not row:
|
|
return {
|
|
"page_key": normalized_key,
|
|
"meta_title": "",
|
|
"meta_description": "",
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
return format_site_page_seo_row(row)
|
|
|
|
|
|
@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(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")
|
|
|
|
payload = format_row(row)
|
|
payload["courses"] = await build_facility_course_payloads(
|
|
conn,
|
|
int(row["id"]),
|
|
include_unpublished_courses=True,
|
|
)
|
|
return payload
|
|
|
|
|
|
@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,
|
|
)
|
|
invalidate_public_api_caches()
|
|
|
|
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),
|
|
}
|
|
|
|
|
|
@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": "",
|
|
"meta_title": "",
|
|
"meta_description": "",
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
return format_place_page_row(row)
|
|
|
|
|
|
@app.get("/api/admin/site-pages/{page_key}")
|
|
async def get_admin_site_page(page_key: str):
|
|
normalized_key = str(page_key or "").strip().lower()
|
|
if normalized_key not in VALID_SITE_PAGE_KEYS:
|
|
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT * FROM site_pages WHERE page_key = $1",
|
|
normalized_key,
|
|
)
|
|
|
|
payload = format_site_page_row(row) if row else build_default_site_page_row(normalized_key)
|
|
if payload is None:
|
|
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
|
return payload
|
|
|
|
|
|
@app.put("/api/admin/site-pages/{page_key}")
|
|
async def update_admin_site_page(page_key: str, request: SitePageUpsertRequest):
|
|
normalized_key = str(page_key or "").strip().lower()
|
|
if normalized_key not in VALID_SITE_PAGE_KEYS:
|
|
raise HTTPException(status_code=404, detail="Side ikke funnet.")
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO site_pages (
|
|
page_key,
|
|
eyebrow,
|
|
title,
|
|
hero_image_url,
|
|
intro_html,
|
|
body_html,
|
|
meta_title,
|
|
meta_description
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (page_key) DO UPDATE
|
|
SET eyebrow = EXCLUDED.eyebrow,
|
|
title = EXCLUDED.title,
|
|
hero_image_url = EXCLUDED.hero_image_url,
|
|
intro_html = EXCLUDED.intro_html,
|
|
body_html = EXCLUDED.body_html,
|
|
meta_title = EXCLUDED.meta_title,
|
|
meta_description = EXCLUDED.meta_description,
|
|
updated_at = NOW()
|
|
RETURNING *
|
|
""",
|
|
normalized_key,
|
|
normalize_optional_text(request.eyebrow),
|
|
normalize_optional_text(request.title),
|
|
normalize_optional_text(request.hero_image_url),
|
|
request.intro_html or "",
|
|
request.body_html or "",
|
|
normalize_optional_text(request.meta_title),
|
|
normalize_optional_text(request.meta_description),
|
|
)
|
|
|
|
config = SITE_PAGE_CONFIGS.get(normalized_key) or {}
|
|
path = str(config.get("path") or "").strip()
|
|
invalidate_public_api_caches(include_site_pages=True)
|
|
if path:
|
|
schedule_indexnow_submission(
|
|
collect_page_indexnow_urls([path]),
|
|
reason="admin site page upsert",
|
|
)
|
|
return format_site_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, meta_title, meta_description)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (slug) DO UPDATE
|
|
SET factbox_intro_html = EXCLUDED.factbox_intro_html,
|
|
meta_title = EXCLUDED.meta_title,
|
|
meta_description = EXCLUDED.meta_description,
|
|
updated_at = NOW()
|
|
RETURNING *
|
|
""",
|
|
normalized_slug,
|
|
request.factbox_intro_html or "",
|
|
normalize_optional_text(request.meta_title),
|
|
normalize_optional_text(request.meta_description),
|
|
)
|
|
|
|
invalidate_public_api_caches(include_place_pages=True)
|
|
schedule_indexnow_submission(
|
|
collect_page_indexnow_urls([f"/sted/{normalized_slug}"]),
|
|
reason="admin place page upsert",
|
|
)
|
|
return format_place_page_row(row)
|
|
|
|
|
|
@app.get("/api/admin/page-seo/{page_key}")
|
|
async def get_admin_site_page_seo(page_key: str):
|
|
normalized_key = str(page_key or "").strip().lower()
|
|
if normalized_key not in VALID_SITE_PAGE_SEO_KEYS:
|
|
raise HTTPException(status_code=404, detail="SEO-side ikke funnet.")
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT * FROM site_page_seo WHERE page_key = $1",
|
|
normalized_key,
|
|
)
|
|
|
|
if not row:
|
|
return {
|
|
"page_key": normalized_key,
|
|
"meta_title": "",
|
|
"meta_description": "",
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
return format_site_page_seo_row(row)
|
|
|
|
|
|
@app.put("/api/admin/page-seo/{page_key}")
|
|
async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRequest):
|
|
normalized_key = str(page_key or "").strip().lower()
|
|
if normalized_key not in VALID_SITE_PAGE_SEO_KEYS:
|
|
raise HTTPException(status_code=404, detail="SEO-side ikke funnet.")
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO site_page_seo (page_key, meta_title, meta_description)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (page_key) DO UPDATE
|
|
SET meta_title = EXCLUDED.meta_title,
|
|
meta_description = EXCLUDED.meta_description,
|
|
updated_at = NOW()
|
|
RETURNING *
|
|
""",
|
|
normalized_key,
|
|
normalize_optional_text(request.meta_title),
|
|
normalize_optional_text(request.meta_description),
|
|
)
|
|
|
|
page_path_map = {
|
|
"golfbaner": "/golfbaner",
|
|
"vtg": "/vtg",
|
|
"medlemskap": "/medlemskap",
|
|
"banebesok": "/banebesok",
|
|
"meninger": "/meninger",
|
|
"simulatorer": "/simulatorer",
|
|
}
|
|
schedule_indexnow_submission(
|
|
collect_page_indexnow_urls([page_path_map[normalized_key]]),
|
|
reason="admin site page seo upsert",
|
|
)
|
|
return format_site_page_seo_row(row)
|
|
|
|
|
|
@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'
|
|
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'
|
|
""", 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()
|
|
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.")
|
|
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")
|
|
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 (
|
|
article_id, user_id, parent_id, body, status, ip_hash, user_agent
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7
|
|
)
|
|
RETURNING *
|
|
""",
|
|
article["id"],
|
|
viewer["id"],
|
|
parent_id,
|
|
body,
|
|
PUBLIC_COMMENT_DEFAULT_STATUS,
|
|
hash_request_ip(request),
|
|
request.headers.get("user-agent"),
|
|
)
|
|
|
|
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),
|
|
}
|
|
|
|
# --- ADMIN ENDPOINTS ---
|
|
|
|
|
|
@app.get("/api/admin/simulator/operators")
|
|
async def get_admin_simulator_operators():
|
|
async with app.state.pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT *
|
|
FROM simulator_operators
|
|
ORDER BY name ASC, id ASC
|
|
"""
|
|
)
|
|
return [format_simulator_operator_row(row) for row in rows]
|
|
|
|
|
|
@app.get("/api/admin/simulator/operator-options")
|
|
async def get_admin_simulator_operator_options():
|
|
async with app.state.pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT id, name, slug
|
|
FROM simulator_operators
|
|
ORDER BY name ASC, id ASC
|
|
"""
|
|
)
|
|
return [format_simulator_operator_row(row) for row in rows]
|
|
|
|
|
|
@app.post("/api/admin/simulator/operators")
|
|
async def create_admin_simulator_operator(request: SimulatorOperatorUpsertRequest):
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await save_simulator_operator(conn, request)
|
|
return format_simulator_operator_row(row)
|
|
|
|
|
|
@app.put("/api/admin/simulator/operators/{operator_id}")
|
|
async def update_admin_simulator_operator(operator_id: int, request: SimulatorOperatorUpsertRequest):
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await save_simulator_operator(conn, request, operator_id=operator_id)
|
|
return format_simulator_operator_row(row)
|
|
|
|
|
|
@app.delete("/api/admin/simulator/operators/{operator_id}")
|
|
async def delete_admin_simulator_operator(operator_id: int):
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
DELETE FROM simulator_operators
|
|
WHERE id = $1
|
|
RETURNING *
|
|
""",
|
|
operator_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Simulatoroperatøren ble ikke funnet.")
|
|
return {
|
|
"status": "success",
|
|
"operator": format_simulator_operator_row(row),
|
|
}
|
|
|
|
|
|
@app.get("/api/admin/simulator/facility-options")
|
|
async def get_admin_simulator_facility_options():
|
|
async with app.state.pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT id, name, slug, city, county
|
|
FROM facilities
|
|
ORDER BY name ASC, id ASC
|
|
"""
|
|
)
|
|
return [format_row(row) for row in rows]
|
|
|
|
|
|
@app.get("/api/admin/simulator/venues")
|
|
async def get_admin_simulator_venues():
|
|
async with app.state.pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
v.*,
|
|
o.name AS operator_name,
|
|
f.name AS facility_name,
|
|
f.slug AS facility_slug
|
|
FROM simulator_venues v
|
|
LEFT JOIN simulator_operators o ON o.id = v.operator_id
|
|
LEFT JOIN facilities f ON f.id = v.facility_id
|
|
ORDER BY v.updated_at DESC, v.name ASC, v.id ASC
|
|
"""
|
|
)
|
|
return [format_simulator_venue_row(row) for row in rows]
|
|
|
|
|
|
@app.post("/api/admin/simulator/venues")
|
|
async def create_admin_simulator_venue(request: SimulatorVenueUpsertRequest):
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await save_simulator_venue(conn, request)
|
|
return format_simulator_venue_row(row)
|
|
|
|
|
|
@app.put("/api/admin/simulator/venues/{venue_id}")
|
|
async def update_admin_simulator_venue(venue_id: int, request: SimulatorVenueUpsertRequest):
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await save_simulator_venue(conn, request, venue_id=venue_id)
|
|
return format_simulator_venue_row(row)
|
|
|
|
|
|
@app.delete("/api/admin/simulator/venues/{venue_id}")
|
|
async def delete_admin_simulator_venue(venue_id: int):
|
|
async with app.state.pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
DELETE FROM simulator_venues
|
|
WHERE id = $1
|
|
RETURNING *
|
|
""",
|
|
venue_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Simulatorstedet ble ikke funnet.")
|
|
return {
|
|
"status": "success",
|
|
"venue": format_simulator_venue_row(row),
|
|
}
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@app.post("/api/admin/articles")
|
|
async def upsert_admin_article(request: ArticleUpsertRequest):
|
|
section = normalize_article_section(request.section)
|
|
status = normalize_article_status(request.status)
|
|
requested_slug = request.slug.strip()
|
|
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)
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
previous_row = await conn.fetchrow(
|
|
"SELECT slug, section, status FROM articles WHERE slug = $1",
|
|
requested_slug,
|
|
)
|
|
row = await conn.fetchrow("""
|
|
INSERT INTO articles (
|
|
section, slug, title, meta_title, meta_description, description, excerpt, eyebrow, location_label,
|
|
facility_name, facility_slug, author_name, status, hero_images,
|
|
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
$10, $11, $12, $13, $14::jsonb,
|
|
$15::jsonb, $16, $17, $18, $19, $20, $21
|
|
)
|
|
ON CONFLICT (slug) DO UPDATE SET
|
|
section = EXCLUDED.section,
|
|
title = EXCLUDED.title,
|
|
meta_title = EXCLUDED.meta_title,
|
|
meta_description = EXCLUDED.meta_description,
|
|
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,
|
|
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,
|
|
requested_slug,
|
|
request.title.strip(),
|
|
(request.meta_title or "").strip() or None,
|
|
(request.meta_description or "").strip() or None,
|
|
(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,
|
|
request.content_html or "",
|
|
(request.source_url or "").strip() or None,
|
|
(request.source_label or "").strip() or None,
|
|
published_at,
|
|
updated_at,
|
|
)
|
|
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
|
|
|
|
|
|
@app.delete("/api/admin/articles/{article_id}")
|
|
async def delete_admin_article(article_id: int):
|
|
async with app.state.pool.acquire() as conn:
|
|
deleted = await conn.fetchrow(
|
|
"DELETE FROM articles WHERE id = $1 RETURNING slug, section, status",
|
|
article_id,
|
|
)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
|
|
deleted_article = format_article_row(deleted)
|
|
schedule_indexnow_submission(
|
|
collect_article_indexnow_urls(previous_article=deleted_article),
|
|
reason="admin article delete",
|
|
)
|
|
return {"status": "success"}
|
|
|
|
|
|
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
|
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate, http_request: Request):
|
|
"""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.")
|
|
|
|
normalized_scrape_method = settings.scrape_method
|
|
if isinstance(normalized_scrape_method, str) and not normalized_scrape_method.strip():
|
|
normalized_scrape_method = "disabled"
|
|
|
|
# Oppdater verdiene i databasen inkludert AI instruks
|
|
await conn.execute("""
|
|
UPDATE facilities
|
|
SET scrape_method = $1,
|
|
scrape_status_url = $2,
|
|
scrape_status_selector = $3,
|
|
ai_instruction = $4
|
|
WHERE id = $5
|
|
""",
|
|
normalized_scrape_method,
|
|
settings.scrape_status_url,
|
|
settings.scrape_status_selector,
|
|
settings.ai_instruction,
|
|
facility_id)
|
|
|
|
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
|
|
if normalized_scrape_method == 'manual' and settings.courses:
|
|
for c in settings.courses:
|
|
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)
|
|
|
|
invalidate_public_api_caches()
|
|
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")
|
|
|
|
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
|
|
@app.put("/api/admin/facilities/{facility_id}/full")
|
|
async def update_facility_full(facility_id: int, request: Request):
|
|
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
|
|
data = apply_legacy_facility_field_aliases(await request.json())
|
|
|
|
async with app.state.pool.acquire() as conn:
|
|
async with conn.transaction():
|
|
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
|
|
invalidate_public_api_caches()
|
|
|
|
schedule_facility_indexnow_submission_for_fields(
|
|
facility_slug,
|
|
changed_field_names,
|
|
reason="facility full update",
|
|
)
|
|
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
|
|
|
|
|
|
@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()
|
|
invalidate_public_api_caches()
|
|
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,
|
|
}
|
|
|
|
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
|
|
@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)
|
|
|
|
|
|
@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,
|
|
)
|
|
|
|
|
|
@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),
|
|
}
|
|
|
|
|
|
@app.post("/api/admin/run-scraper")
|
|
async def run_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
|
|
"""Legger banestatus-skraping i en persistent jobbkø."""
|
|
print(f"📡 API mottok forespørsel om å kjøre banestatus-skraping for IDer: {request.facility_ids}")
|
|
return await queue_scrape_job("banestatus", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
|
|
|
|
|
|
@app.post("/api/admin/run-membership-scraper")
|
|
async def run_membership_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
|
|
"""Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
|
|
print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
|
|
return await queue_scrape_job("medlemskap", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
|
|
|
|
@app.get("/api/health")
|
|
async def health_check():
|
|
"""Enkel sjekk for å se at API og DB lever."""
|
|
try:
|
|
async with app.state.pool.acquire() as conn:
|
|
await conn.execute("SELECT 1")
|
|
return {"status": "healthy", "database": "connected"}
|
|
except Exception as e:
|
|
print(f"❌ Health check feilet: {type(e).__name__}")
|
|
return {"status": "unhealthy", "error": "database_unavailable"}
|
|
|
|
# --- 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."""
|
|
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)
|
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
|
invalidate_public_api_caches()
|
|
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!"}
|
|
|
|
@app.patch("/api/admin/facilities/{facility_id}/quick-edit")
|
|
async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
|
|
"""Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
|
|
# Sikkerhet: Tillat KUN disse URL-/tekstfeltene for hurtigredigering
|
|
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'greenfee_url', 'golfpakker_url', 'vtg_lenke', 'scrape_status_selector', 'footnote', 'website_url']
|
|
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:
|
|
facility_slug = str(
|
|
await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
|
|
).strip()
|
|
if request.field == 'footnote':
|
|
normalized_value = str(request.value or '').strip() or None
|
|
footnote_updated_at = datetime.utcnow() if normalized_value else None
|
|
await conn.execute(
|
|
"""
|
|
UPDATE facilities
|
|
SET footnote = $1,
|
|
footnote_updated_at = $2
|
|
WHERE id = $3
|
|
""",
|
|
normalized_value,
|
|
footnote_updated_at,
|
|
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)
|
|
invalidate_public_api_caches()
|
|
schedule_indexnow_submission(
|
|
collect_facility_indexnow_urls([facility_slug], extra_paths=["/golfbaner"]),
|
|
reason=f"facility quick edit ({request.field})",
|
|
)
|
|
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."""
|
|
facility_ids = [approval.facility_id for approval in request.approvals]
|
|
async with app.state.pool.acquire() as conn:
|
|
async with conn.transaction():
|
|
facility_columns = await get_table_columns(conn, "facilities")
|
|
has_cooperating_clubs = "cooperating_clubs" in facility_columns
|
|
|
|
for approval in request.approvals:
|
|
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,
|
|
)
|
|
|
|
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)
|
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
|
invalidate_public_api_caches()
|
|
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")
|
|
async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
|
|
"""Tar imot IDer for greenfeeskraping og legger jobben i kø."""
|
|
print(f"📡 API mottok forespørsel om greenfee-skraping for IDer: {request.facility_ids}")
|
|
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)
|
|
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):
|
|
ensure_valid_vtg_course_rows(request.approvals)
|
|
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)
|
|
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."""
|
|
ensure_valid_vtg_course_rows(request.approvals)
|
|
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,
|
|
)
|
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
|
invalidate_public_api_caches()
|
|
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")
|
|
async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
|
|
"""Tar imot IDer for VTG-skraping og legger jobben i kø."""
|
|
print(f"📡 API mottok forespørsel om VTG-skraping for IDer: {request.facility_ids}")
|
|
return await queue_scrape_job("vtg", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
|
|
|
|
|
|
@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."""
|
|
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 golfpakker = $1::jsonb,
|
|
golfpakker_updated_at = NOW(),
|
|
golfpakker_draft = NULL
|
|
WHERE id = $2
|
|
""", json.dumps(approval.golfpakker), approval.facility_id)
|
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
|
invalidate_public_api_caches()
|
|
schedule_indexnow_submission(
|
|
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
|
|
reason="golfpakker bulk approval",
|
|
)
|
|
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))
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|