From dc7ed19f0201ea22b0304d611edbc8af18402942 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sun, 26 Apr 2026 09:52:05 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20hastighets=C3=B8kning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/course_status_history.py | 137 ++++ backend/main.py | 608 +++++++++++------- frontend/src/app/admin/nytt-anlegg/page.tsx | 31 + frontend/src/app/admin/page.tsx | 6 + .../rediger/[slug]/EditFacilityClient.tsx | 125 +++- frontend/src/app/facilityAliases.ts | 29 +- .../golfbaner/[slug]/FacilityDetailView.tsx | 50 +- 7 files changed, 704 insertions(+), 282 deletions(-) create mode 100644 backend/course_status_history.py create mode 100644 frontend/src/app/admin/nytt-anlegg/page.tsx 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 ; +} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 5517afa..ab5f585 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1462,6 +1462,12 @@ export default function AdminDashboard() {
+ + Nytt anlegg + { return []; }; +const normalizeStringArray = (value: any): string[] => { + if (Array.isArray(value)) { + return value.map((entry) => String(entry || "").trim()).filter(Boolean); + } + + if (typeof value === 'string') { + try { + return normalizeStringArray(JSON.parse(value)); + } catch { + return []; + } + } + + return []; +}; + +const slugify = (value: string) => + value + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const getMediaFieldLabel = (field: string) => { if (field === 'image_url') return 'hovedbildet'; if (field === 'logo_url') return 'logoen'; @@ -480,9 +504,10 @@ const getMediaFieldLabel = (field: string) => { export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) { const router = useRouter(); + const isCreateMode = typeof initialData?.id !== 'number'; const [formData, setFormData] = useState({ ...initialData, - is_published: initialData?.is_published !== false, + is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false, courses: Array.isArray(initialData?.courses) ? initialData.courses : [], }); const [activeTab, setActiveTab] = useState('generelt'); @@ -490,6 +515,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini const [deletingFacility, setDeletingFacility] = useState(false); const [mediaFeedback, setMediaFeedback] = useState(""); const [uploadingTarget, setUploadingTarget] = useState(null); + const [slugTouched, setSlugTouched] = useState(Boolean(initialData?.slug)); const mainImageInputRef = useRef(null); const logoImageInputRef = useRef(null); const galleryInputRef = useRef(null); @@ -499,14 +525,27 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini // Sørg for at cooperating_clubs er et array const [coopClubs, setCoopClubs] = useState( - Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs : - (typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : []) + normalizeStringArray(initialData?.cooperating_clubs) ); + const facilityName = String(formData.name || initialData?.name || "").trim() || "Nytt anlegg"; + const facilitySlug = String(formData.slug || initialData?.slug || "").trim(); + const publicFacilityUrl = facilitySlug ? `/golfbaner/${facilitySlug}` : ""; + const handleChange = (field: string, value: any) => { setFormData((prev: any) => ({ ...prev, [field]: value })); }; + const handleNameChange = (value: string) => { + setFormData((prev: any) => { + const next = { ...prev, name: value }; + if (isCreateMode && !slugTouched) { + next.slug = slugify(value); + } + return next; + }); + }; + const updateCourses = (updater: (courses: any[]) => any[]) => { const nextCourses = updater(Array.isArray(formData.courses) ? formData.courses : []); handleChange('courses', nextCourses); @@ -656,17 +695,31 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini const handleSave = async () => { setSaving(true); try { - const res = await adminFetch(`/api/admin/facilities/${initialData.id}/full`, { - method: 'PUT', + const endpoint = isCreateMode ? '/api/admin/facilities' : `/api/admin/facilities/${initialData.id}/full`; + const method = isCreateMode ? 'POST' : 'PUT'; + const res = await adminFetch(endpoint, { + method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); - + if (res.ok) { - alert("Lagret suksessfullt!"); - router.refresh(); + if (isCreateMode) { + const payload = await res.json(); + const createdSlug = String(payload?.facility?.slug || "").trim(); + if (!createdSlug) { + alert("Anlegget ble opprettet, men mangler slug i svaret."); + return; + } + router.push(`/admin/rediger/${createdSlug}`); + router.refresh(); + } else { + alert("Lagret suksessfullt!"); + router.refresh(); + } } else { - alert("Noe gikk galt under lagring."); + const error = await res.json().catch(() => null); + alert(error?.detail || "Noe gikk galt under lagring."); } } catch (e) { alert("Nettverksfeil."); @@ -675,6 +728,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini }; const handleDeleteFacility = async () => { + if (isCreateMode) return; const confirmed = window.confirm(`Slette anlegget "${initialData.name}" permanent? Dette fjerner også baner og hull.`); if (!confirmed) return; @@ -723,42 +777,44 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
← Tilbake til oversikten

- Rediger:{" "} - {formData.is_published ? ( + {isCreateMode ? "Nytt anlegg: " : "Rediger: "} + {!isCreateMode && formData.is_published && publicFacilityUrl ? ( - {initialData.name} + {facilityName} ) : ( - {initialData.name} + {facilityName} )}

{formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'} - Slug: {initialData.slug} + Slug: {facilitySlug || 'ikke satt ennå'}

- + {!isCreateMode ? ( + + ) : null}
@@ -801,8 +857,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
- handleChange('name', e.target.value)} /> + handleNameChange(e.target.value)} />
+ {isCreateMode ? ( +
+ + { + setSlugTouched(true); + handleChange('slug', slugify(e.target.value)); + }} + placeholder="f.eks. oslo-golfklubb" + /> +

Brukes i URL-en. Hvis du lar feltet stå tomt, foreslås slug automatisk fra navnet.

+
+ ) : null}
@@ -932,7 +1003,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{getValue('image_url', 'text') ? ( - {`${initialData.name} + {`${facilityName} ) : (
Ingen hovedbilde valgt @@ -978,7 +1049,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{getValue('logo_url', 'text') ? ( - {`${initialData.name} + {`${facilityName} ) : (
Ingen logo valgt @@ -1024,7 +1095,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
- {`${initialData.name} + {`${facilityName}
diff --git a/frontend/src/app/facilityAliases.ts b/frontend/src/app/facilityAliases.ts index ee93be6..49e2cce 100644 --- a/frontend/src/app/facilityAliases.ts +++ b/frontend/src/app/facilityAliases.ts @@ -1,4 +1,3 @@ -import { unstable_cache } from "next/cache"; import { API_URL } from "@/config/constants"; import { getAvailablePlaceConfigs, slugify } from "@/app/facilityData"; @@ -115,23 +114,19 @@ function buildFacilityAliasMap(facilities: FacilityAliasSource[]) { return Object.fromEntries(aliases); } -const getCachedFacilityAliasMap = unstable_cache( - async () => { - const response = await fetch(`${API_URL}/facilities`, { - next: { revalidate: 3600 }, - }); +async function getFacilityAliasMap() { + const response = await fetch(`${API_URL}/facilities?summary=true`, { + cache: "no-store", + }); - if (!response.ok) { - return {}; - } + if (!response.ok) { + return {}; + } - const data = await response.json(); - const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : []; - return buildFacilityAliasMap(facilities); - }, - ["facility-short-aliases"], - { revalidate: 3600 }, -); + const data = await response.json(); + const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : []; + return buildFacilityAliasMap(facilities); +} export async function resolveFacilityAlias(alias: string) { const normalizedAlias = slugify(alias); @@ -140,6 +135,6 @@ export async function resolveFacilityAlias(alias: string) { return MANUAL_FACILITY_ALIASES[normalizedAlias]; } - const aliasMap = await getCachedFacilityAliasMap(); + const aliasMap = await getFacilityAliasMap(); return aliasMap[normalizedAlias] || null; } diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index 52711c8..e68c43e 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -37,6 +37,41 @@ const formatPhoneForUrl = (phone: string) => { return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized; }; +const getYouTubeEmbedUrl = (url: string) => { + const match = url.match( + /(?:youtube\.com\/watch\?v=|youtube\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/i, + ); + return match ? `https://www.youtube.com/embed/${match[1]}?rel=0` : null; +}; + +const getVimeoEmbedUrl = (url: string) => { + const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i); + return match ? `https://player.vimeo.com/video/${match[1]}` : null; +}; + +const getFacilityVideoEmbedUrl = (url: string | null | undefined) => { + const raw = String(url || "").trim(); + if (!raw) return null; + return getYouTubeEmbedUrl(raw) || getVimeoEmbedUrl(raw) || raw; +}; + +const getYrMeteogramUrl = (url: string | null | undefined) => { + const raw = String(url || "").trim(); + if (!raw) return null; + + const normalized = raw.replace(/\/$/, ""); + const locationIdMatch = normalized.match(/\/(10-\d+)(?:\/|$)/); + if (locationIdMatch) { + return `https://www.yr.no/nb/innhold/${locationIdMatch[1]}/meteogram.svg`; + } + + if (normalized.includes("/graf/dag/")) { + return `${normalized.replace("/graf/dag/", "/innhold/")}/meteogram.svg`; + } + + return `${normalized}/meteogram.svg`; +}; + const renderValue = (val: unknown, fallback = "Nei") => { const raw = String(val || "").trim(); if (!raw) return fallback; @@ -285,7 +320,8 @@ export default function FacilityDetailView({ }; const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null; - const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg"; + const weatherImg = getYrMeteogramUrl(facility.weather_url); + const videoEmbedUrl = getFacilityVideoEmbedUrl(facility.video_url); const getGalleryImageAlt = (imageUrl: string) => { const normalized = String(imageUrl || "").trim().toLowerCase(); if (!normalized) { @@ -601,7 +637,7 @@ export default function FacilityDetailView({

Vær for {facility.name}

- {facility.weather_url ? ( {`Værvarsel ) :

Værvarsel ikke tilgjengelig

} + {weatherImg ? ( {`Værvarsel ) :

Værvarsel ikke tilgjengelig

}
@@ -624,11 +660,17 @@ export default function FacilityDetailView({ )} {/* 7. VIDEO SEKSJON */} - {facility.video_url && ( + {videoEmbedUrl && (

Video

-