diff --git a/backend/course_status_history.py b/backend/course_status_history.py
new file mode 100644
index 0000000..2039322
--- /dev/null
+++ b/backend/course_status_history.py
@@ -0,0 +1,137 @@
+from datetime import date, datetime, time, timedelta
+from zoneinfo import ZoneInfo
+
+
+OSLO_TIMEZONE = ZoneInfo("Europe/Oslo")
+
+
+def get_oslo_today() -> date:
+ return datetime.now(OSLO_TIMEZONE).date()
+
+
+async def ensure_course_status_history_table(conn) -> None:
+ await conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS course_status_history (
+ id SERIAL PRIMARY KEY,
+ course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+ facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
+ old_status TEXT,
+ new_status TEXT NOT NULL,
+ change_source TEXT NOT NULL,
+ changed_by TEXT,
+ changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """
+ )
+ await conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_course_status_history_changed_at
+ ON course_status_history (changed_at DESC)
+ """
+ )
+ await conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_course_status_history_course_id
+ ON course_status_history (course_id, changed_at DESC)
+ """
+ )
+ await conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_course_status_history_facility_id
+ ON course_status_history (facility_id, changed_at DESC)
+ """
+ )
+
+
+async def log_course_status_change(
+ conn,
+ *,
+ course_id: int,
+ facility_id: int,
+ old_status: str | None,
+ new_status: str | None,
+ change_source: str,
+ changed_by: str | None = None,
+) -> bool:
+ normalized_old = str(old_status or "").strip().lower() or "ukjent"
+ normalized_new = str(new_status or "").strip().lower() or "ukjent"
+ normalized_source = str(change_source or "").strip().lower()
+ normalized_changed_by = str(changed_by or "").strip() or None
+
+ if not normalized_source:
+ raise ValueError("change_source må være satt")
+
+ if normalized_old == normalized_new:
+ return False
+
+ await conn.execute(
+ """
+ INSERT INTO course_status_history (
+ course_id,
+ facility_id,
+ old_status,
+ new_status,
+ change_source,
+ changed_by
+ )
+ VALUES ($1, $2, $3, $4, $5, $6)
+ """,
+ course_id,
+ facility_id,
+ normalized_old,
+ normalized_new,
+ normalized_source,
+ normalized_changed_by,
+ )
+ return True
+
+
+async def list_course_status_history(conn, *, changed_on: date | None = None, limit: int = 100):
+ target_date = changed_on or get_oslo_today()
+ start_at = datetime.combine(target_date, time.min, tzinfo=OSLO_TIMEZONE)
+ end_at = start_at + timedelta(days=1)
+
+ rows = await conn.fetch(
+ """
+ SELECT
+ h.id,
+ h.course_id,
+ h.facility_id,
+ h.old_status,
+ h.new_status,
+ h.change_source,
+ h.changed_by,
+ h.changed_at,
+ c.name AS course_name,
+ f.name AS facility_name,
+ f.slug AS facility_slug
+ FROM course_status_history h
+ JOIN courses c ON c.id = h.course_id
+ JOIN facilities f ON f.id = h.facility_id
+ WHERE h.changed_at >= $1
+ AND h.changed_at < $2
+ ORDER BY h.changed_at DESC, h.id DESC
+ LIMIT $3
+ """,
+ start_at,
+ end_at,
+ limit,
+ )
+
+ return [
+ {
+ "id": row["id"],
+ "course_id": row["course_id"],
+ "facility_id": row["facility_id"],
+ "old_status": row["old_status"],
+ "new_status": row["new_status"],
+ "change_source": row["change_source"],
+ "changed_by": row["changed_by"],
+ "changed_at": row["changed_at"].isoformat() if row["changed_at"] else None,
+ "course_name": row["course_name"],
+ "facility_name": row["facility_name"],
+ "facility_slug": row["facility_slug"],
+ }
+ for row in rows
+ ]
diff --git a/backend/main.py b/backend/main.py
index ca8381f..d88e4f6 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -22,6 +22,7 @@ import re
import secrets
import hashlib
import smtplib
+import unicodedata
from datetime import datetime, date, timedelta
from email.message import EmailMessage
from pathlib import Path
@@ -41,6 +42,12 @@ from scrape_jobs import (
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, normalize_vtg_course_rows
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
@@ -679,6 +686,43 @@ class PublicFacilityFeedbackRequest(BaseModel):
message: str
website: Optional[str] = ""
started_at: Optional[int] = None
+
+
+LEGACY_FACILITY_FIELD_ALIASES = {
+ 'vtg_presentasjon': 'vtg_beskrivelse',
+ 'vtg_kursdatoer': 'vtg_datoer',
+}
+
+FACILITY_ALLOWED_FIELDS = [
+ 'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
+ 'address', 'zipcode', 'city', 'county', 'lat', 'lng',
+ 'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
+ 'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
+ 'image_url', 'logo_url', 'front_image_url', 'gallery',
+ 'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
+ 'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data',
+ 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
+ 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
+ 'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
+ 'guest_requirements', 'scrape_method', 'scrape_status_url',
+ 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
+ 'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
+ 'vtg_updated_at', 'vtg_draft', 'vtg_content_draft', 'vtg_courses_draft',
+ 'vtg_content_updated_at', 'vtg_courses_updated_at', 'footnote_updated_at', 'is_published',
+ 'golfpakker_draft', 'golfpakker_updated_at'
+]
+
+FACILITY_MEMBERSHIP_FIELDS = {
+ 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
+ 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at'
+}
+FACILITY_VTG_CONTENT_FIELDS = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris'}
+FACILITY_VTG_COURSE_FIELDS = {'vtg_datoer'}
+FACILITY_VTG_FIELDS = FACILITY_VTG_CONTENT_FIELDS | FACILITY_VTG_COURSE_FIELDS | {
+ 'vtg_updated_at',
+ 'vtg_content_updated_at',
+ 'vtg_courses_updated_at',
+}
# --- FUNKSJONER ---
def format_row(row):
"""
@@ -1151,6 +1195,227 @@ def sanitize_featured_media_id(featured_media_id: str | None, media_gallery: lis
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,
+ )
+
+
+async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tuple[str, set[str]]:
+ normalized_data = apply_legacy_facility_field_aliases(data)
+ update_data = {k: v for k, v in normalized_data.items() if k in FACILITY_ALLOWED_FIELDS}
+ changed_field_names = set(update_data.keys())
+
+ facility_slug = str(
+ await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
+ ).strip()
+ if not facility_slug:
+ raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
+
+ facility_columns = await get_table_columns(conn, "facilities")
+ update_data = {k: v for k, v in update_data.items() if k in facility_columns}
+
+ if update_data:
+ if 'footnote' in update_data and 'footnote_updated_at' not in update_data:
+ existing_footnote = await conn.fetchval(
+ "SELECT footnote FROM facilities WHERE id = $1",
+ facility_id
+ )
+ incoming_footnote = str(update_data.get('footnote') or '').strip()
+ current_footnote = str(existing_footnote or '').strip()
+
+ if incoming_footnote != current_footnote:
+ update_data['footnote_updated_at'] = datetime.utcnow() if incoming_footnote else None
+
+ set_clauses = []
+ values = []
+
+ date_fields = [
+ 'membership_updated_at',
+ 'greenfee_updated_at',
+ 'vtg_updated_at',
+ 'vtg_content_updated_at',
+ 'vtg_courses_updated_at',
+ 'status_updated_at',
+ 'footnote_updated_at',
+ 'golfpakker_updated_at'
+ ]
+
+ if changed_field_names & FACILITY_VTG_CONTENT_FIELDS:
+ vtg_content_ts = datetime.utcnow()
+ update_data.setdefault('vtg_content_updated_at', vtg_content_ts)
+ update_data.setdefault('vtg_updated_at', vtg_content_ts)
+
+ if changed_field_names & FACILITY_VTG_COURSE_FIELDS:
+ vtg_course_ts = datetime.utcnow()
+ update_data.setdefault('vtg_courses_updated_at', vtg_course_ts)
+ update_data.setdefault('vtg_updated_at', vtg_course_ts)
+
+ for i, (k, v) in enumerate(update_data.items(), 1):
+ if isinstance(v, (dict, list)):
+ set_clauses.append(f"{k} = ${i}::jsonb")
+ values.append(json.dumps(v))
+ elif k in date_fields:
+ set_clauses.append(f"{k} = ${i}")
+ if v == "" or v is None:
+ values.append(None)
+ else:
+ dt_str = str(v).replace("Z", "+00:00")
+ try:
+ dt_obj = datetime.fromisoformat(dt_str)
+ values.append(dt_obj)
+ except ValueError:
+ values.append(None)
+ else:
+ set_clauses.append(f"{k} = ${i}")
+ values.append(v)
+
+ values.append(facility_id)
+ query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
+ await conn.execute(query, *values)
+
+ if 'vtg_datoer' in update_data:
+ await replace_facility_vtg_courses(conn, facility_id, update_data.get('vtg_datoer'))
+
+ if 'courses' in normalized_data:
+ submitted_courses = [course for course in (normalized_data.get('courses') or []) if course]
+ normalized_courses: list[dict[str, Any]] = []
+
+ for course in submitted_courses:
+ normalized_course = dict(course)
+ normalized_course['is_main_course'] = bool(course.get('is_main_course'))
+ normalized_courses.append(normalized_course)
+
+ if normalized_courses:
+ if not any(course['is_main_course'] for course in normalized_courses):
+ normalized_courses[0]['is_main_course'] = True
+ else:
+ main_assigned = False
+ for course in normalized_courses:
+ if course['is_main_course'] and not main_assigned:
+ main_assigned = True
+ else:
+ course['is_main_course'] = False
+
+ retained_course_ids: list[int] = []
+
+ for course in normalized_courses:
+ course_id = course.get('id')
+ holes = [hole for hole in (course.get('holes') or []) if hole]
+ hole_count = len(holes) or None
+ course_par = parse_optional_int(course.get('par'))
+ course_length_meters = parse_optional_int(course.get('length_meters'))
+
+ valid_until_str = course.get('slope_valid_until')
+ if valid_until_str == "" or valid_until_str is None:
+ valid_until = None
+ else:
+ try:
+ date_part = str(valid_until_str).split('T')[0]
+ valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
+ except ValueError:
+ valid_until = None
+
+ tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
+
+ if course_id:
+ await conn.execute("""
+ UPDATE courses
+ SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
+ status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
+ slope_valid_until=$9
+ WHERE id=$10 AND facility_id=$11
+ """,
+ course.get('name'), hole_count, course_par, course_length_meters,
+ course.get('architect'), course.get('status'), course.get('is_main_course'),
+ tee_boxes_json, valid_until, course_id, facility_id)
+ else:
+ course_id = await conn.fetchval("""
+ INSERT INTO courses (
+ facility_id, name, holes, par, length_meters, architect,
+ status, is_main_course, tee_boxes, slope_valid_until
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
+ RETURNING id
+ """,
+ facility_id, course.get('name'), hole_count, course_par, course_length_meters,
+ course.get('architect'), course.get('status'), course.get('is_main_course'),
+ tee_boxes_json, valid_until)
+
+ retained_course_ids.append(int(course_id))
+
+ retained_hole_ids: list[int] = []
+ for hole in holes:
+ hole_id = hole.get('id')
+ hole_number = parse_optional_int(hole.get('hole_number'))
+ hole_par = parse_optional_int(hole.get('par'))
+ hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
+ lengths_json = json.dumps(hole.get('lengths') or {})
+ if hole_id:
+ await conn.execute("""
+ UPDATE holes
+ SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
+ WHERE id=$5 AND course_id=$6
+ """,
+ hole_number, hole_par, hole_hcp_index,
+ lengths_json, hole_id, course_id)
+ else:
+ hole_id = await conn.fetchval("""
+ INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
+ VALUES ($1, $2, $3, $4, $5::jsonb)
+ RETURNING id
+ """,
+ course_id, hole_number, hole_par, hole_hcp_index,
+ lengths_json)
+
+ retained_hole_ids.append(int(hole_id))
+
+ if retained_hole_ids:
+ await conn.execute(
+ "DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
+ course_id,
+ retained_hole_ids,
+ )
+ else:
+ await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
+
+ if retained_course_ids:
+ await conn.execute(
+ "DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
+ facility_id,
+ retained_course_ids,
+ )
+ else:
+ await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id)
+
+ return facility_slug, changed_field_names
+
+
def build_hero_images_from_media_gallery(
media_gallery: list[dict[str, str]],
fallback_hero_images: list[dict[str, str]],
@@ -1825,6 +2090,7 @@ async def lifespan(app: FastAPI):
await ensure_articles_table(conn)
await ensure_public_user_tables(conn)
await ensure_scrape_jobs_table(conn)
+ await ensure_course_status_history_table(conn)
await ensure_weather_forecast_table(conn)
app.state.weather_sync_stop_event = asyncio.Event()
app.state.weather_sync_task = asyncio.create_task(
@@ -2827,6 +3093,70 @@ async def get_admin_facility(slug: str):
return format_row(row)
+@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,
+ )
+
+ 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()
@@ -3268,7 +3598,7 @@ async def delete_admin_article(article_id: int):
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
-async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
+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:
@@ -3295,7 +3625,29 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
if settings.scrape_method == 'manual' and settings.courses:
for c in settings.courses:
- await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id)
+ 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)
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
@@ -3308,241 +3660,15 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
@app.put("/api/admin/facilities/{facility_id}/full")
async def update_facility_full(facility_id: int, request: Request):
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
- data = await request.json()
+ data = apply_legacy_facility_field_aliases(await request.json())
- legacy_field_aliases = {
- 'vtg_presentasjon': 'vtg_beskrivelse',
- 'vtg_kursdatoer': 'vtg_datoer',
- }
- for legacy_field, canonical_field in legacy_field_aliases.items():
- if legacy_field in data and canonical_field not in data:
- data[canonical_field] = data[legacy_field]
-
- # Felter som er trygge å oppdatere manuelt på anlegget
- allowed_fields = [
- 'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
- 'address', 'zipcode', 'city', 'county', 'lat', 'lng',
- 'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
- 'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
- 'image_url', 'logo_url', 'front_image_url', 'gallery',
- 'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
- 'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data',
- 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
- 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
- 'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
- 'guest_requirements', 'scrape_method', 'scrape_status_url',
- 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
- 'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
- 'vtg_updated_at', 'vtg_draft', 'vtg_content_draft', 'vtg_courses_draft',
- 'vtg_content_updated_at', 'vtg_courses_updated_at', 'footnote_updated_at', 'is_published',
- 'golfpakker_draft', 'golfpakker_updated_at'
- ]
-
- update_data = {k: v for k, v in data.items() if k in allowed_fields}
- membership_fields = {
- 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
- 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at'
- }
- vtg_content_fields = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris'}
- vtg_course_fields = {'vtg_datoer'}
- vtg_fields = vtg_content_fields | vtg_course_fields | {'vtg_updated_at', 'vtg_content_updated_at', 'vtg_courses_updated_at'}
- changed_field_names = set(update_data.keys())
-
- facility_slug = ""
async with app.state.pool.acquire() as conn:
- async with conn.transaction(): # Sikrer at alt lagres samlet
- facility_slug = str(
- await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
- ).strip()
- facility_columns = await get_table_columns(conn, "facilities")
- update_data = {k: v for k, v in update_data.items() if k in facility_columns}
-
- # 1. OPPDATER ANLEGG (FACILITIES)
- if update_data:
- if 'footnote' in update_data and 'footnote_updated_at' not in update_data:
- existing_footnote = await conn.fetchval(
- "SELECT footnote FROM facilities WHERE id = $1",
- facility_id
- )
- incoming_footnote = str(update_data.get('footnote') or '').strip()
- current_footnote = str(existing_footnote or '').strip()
+ async with conn.transaction():
+ facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
- if incoming_footnote != current_footnote:
- update_data['footnote_updated_at'] = datetime.utcnow() if incoming_footnote else None
-
- set_clauses = []
- values = []
-
- # Definer hvilke felt som er datoer i databasen
- 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 & 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 & 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}")
- # Håndter tomme datoer og konverter til Python datetime
- if v == "" or v is None:
- values.append(None)
- else:
- # Tving strengen over til et ekte datetime-objekt.
- # .replace() håndterer Next.js' "Z"-format.
- dt_str = str(v).replace("Z", "+00:00")
- try:
- dt_obj = datetime.fromisoformat(dt_str)
- values.append(dt_obj)
- except ValueError:
- values.append(None)
- else:
- set_clauses.append(f"{k} = ${i}")
- values.append(v)
-
- values.append(facility_id)
- query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
- await conn.execute(query, *values)
-
- if 'vtg_datoer' in update_data:
- await replace_facility_vtg_courses(conn, facility_id, update_data.get('vtg_datoer'))
-
- # 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
- if 'courses' in data:
- submitted_courses = [course for course in (data.get('courses') or []) if course]
- normalized_courses: list[dict[str, Any]] = []
-
- for index, course in enumerate(submitted_courses):
- normalized_course = dict(course)
- normalized_course['is_main_course'] = bool(course.get('is_main_course'))
- normalized_courses.append(normalized_course)
-
- if normalized_courses:
- if not any(course['is_main_course'] for course in normalized_courses):
- normalized_courses[0]['is_main_course'] = True
- else:
- main_assigned = False
- for course in normalized_courses:
- if course['is_main_course'] and not main_assigned:
- main_assigned = True
- else:
- course['is_main_course'] = False
-
- retained_course_ids: list[int] = []
-
- for course in normalized_courses:
- course_id = course.get('id')
- holes = [hole for hole in (course.get('holes') or []) if hole]
- hole_count = len(holes) or None
- course_par = parse_optional_int(course.get('par'))
- course_length_meters = parse_optional_int(course.get('length_meters'))
-
- valid_until_str = course.get('slope_valid_until')
- if valid_until_str == "" or valid_until_str is None:
- valid_until = None
- else:
- try:
- date_part = str(valid_until_str).split('T')[0]
- valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
- except ValueError:
- valid_until = None
-
- tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
-
- if course_id:
- await conn.execute("""
- UPDATE courses
- SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
- status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
- slope_valid_until=$9
- WHERE id=$10 AND facility_id=$11
- """,
- course.get('name'), hole_count, course_par, course_length_meters,
- course.get('architect'), course.get('status'), course.get('is_main_course'),
- tee_boxes_json, valid_until, course_id, facility_id)
- else:
- course_id = await conn.fetchval("""
- INSERT INTO courses (
- facility_id, name, holes, par, length_meters, architect,
- status, is_main_course, tee_boxes, slope_valid_until
- )
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
- RETURNING id
- """,
- facility_id, course.get('name'), hole_count, course_par, course_length_meters,
- course.get('architect'), course.get('status'), course.get('is_main_course'),
- tee_boxes_json, valid_until)
-
- retained_course_ids.append(int(course_id))
-
- retained_hole_ids: list[int] = []
- for hole in holes:
- hole_id = hole.get('id')
- hole_number = parse_optional_int(hole.get('hole_number'))
- hole_par = parse_optional_int(hole.get('par'))
- hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
- lengths_json = json.dumps(hole.get('lengths') or {})
- if hole_id:
- await conn.execute("""
- UPDATE holes
- SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
- WHERE id=$5 AND course_id=$6
- """,
- hole_number, hole_par, hole_hcp_index,
- lengths_json, hole_id, course_id)
- else:
- hole_id = await conn.fetchval("""
- INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
- VALUES ($1, $2, $3, $4, $5::jsonb)
- RETURNING id
- """,
- course_id, hole_number, hole_par, hole_hcp_index,
- lengths_json)
-
- retained_hole_ids.append(int(hole_id))
-
- if retained_hole_ids:
- await conn.execute(
- "DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
- course_id,
- retained_hole_ids,
- )
- else:
- await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
-
- if retained_course_ids:
- await conn.execute(
- "DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
- facility_id,
- retained_course_ids,
- )
- else:
- await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id)
-
- extra_paths = ["/golfbaner"]
- if changed_field_names & membership_fields:
- extra_paths.append("/medlemskap")
- if changed_field_names & vtg_fields:
- extra_paths.append("/vtg")
- schedule_indexnow_submission(
- collect_facility_indexnow_urls([facility_slug], extra_paths=extra_paths),
+ 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."}
@@ -3580,6 +3706,20 @@ async def get_scrape_jobs(job_type: Optional[str] = Query(default=None), limit:
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."""
diff --git a/frontend/src/app/admin/nytt-anlegg/page.tsx b/frontend/src/app/admin/nytt-anlegg/page.tsx
new file mode 100644
index 0000000..039c10f
--- /dev/null
+++ b/frontend/src/app/admin/nytt-anlegg/page.tsx
@@ -0,0 +1,31 @@
+import { cookies } from "next/headers";
+import { API_URL } from "@/config/constants";
+import EditFacilityClient from "../rediger/[slug]/EditFacilityClient";
+
+const EMPTY_FACILITY = {
+ name: "",
+ slug: "",
+ is_published: false,
+ courses: [],
+ gallery: [],
+ greenfee: [],
+ golfpakker: [],
+ social_links: [],
+ vtg_datoer: [],
+ cooperating_clubs: [],
+ amenities: {},
+ nsg_data: {},
+ golfamore_data: {},
+};
+
+export default async function NewFacilityPage() {
+ const cookieHeader = (await cookies()).toString();
+ const allRes = await fetch(`${API_URL}/admin/facilities`, {
+ cache: "no-store",
+ headers: cookieHeader ? { cookie: cookieHeader } : undefined,
+ });
+
+ const allFacilities = allRes.ok ? await allRes.json() : [];
+
+ return
{formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'} - Slug: {initialData.slug} + Slug: {facilitySlug || 'ikke satt ennå'}
Brukes i URL-en. Hvis du lar feltet stå tomt, foreslås slug automatisk fra navnet.
+Værvarsel ikke tilgjengelig
} + {weatherImg ? (Værvarsel ikke tilgjengelig
}