Før hastighetsøkning

This commit is contained in:
Erol Haagenrud 2026-04-26 09:52:05 +02:00
parent fec5f4e8c6
commit dc7ed19f02
7 changed files with 704 additions and 282 deletions

View file

@ -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
]

View file

@ -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."""

View file

@ -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 <EditFacilityClient initialData={EMPTY_FACILITY} allFacilities={allFacilities} />;
}

View file

@ -1462,6 +1462,12 @@ export default function AdminDashboard() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/admin/nytt-anlegg"
className="btn btn-md btn-primary"
>
Nytt anlegg
</Link>
<Link
href="/admin/steder"
className="btn btn-md btn-secondary"

View file

@ -472,6 +472,30 @@ const normalizeStringList = (value: any): string[] => {
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<string | null>(null);
const [slugTouched, setSlugTouched] = useState(Boolean(initialData?.slug));
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
const logoImageInputRef = useRef<HTMLInputElement | null>(null);
const galleryInputRef = useRef<HTMLInputElement | null>(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<string[]>(
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
<div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black text-[#11280f]">
Rediger:{" "}
{formData.is_published ? (
{isCreateMode ? "Nytt anlegg: " : "Rediger: "}
{!isCreateMode && formData.is_published && publicFacilityUrl ? (
<Link
href={`/golfbaner/${initialData.slug}`}
href={publicFacilityUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[#8bc34a]"
title="Åpne anleggssiden i ny fane"
>
{initialData.name}
{facilityName}
</Link>
) : (
<span className="text-[#8bc34a]">{initialData.name}</span>
<span className="text-[#8bc34a]">{facilityName}</span>
)}
</h1>
<p className="mt-3 flex flex-wrap items-center gap-3 text-xs font-black uppercase tracking-widest">
<span className={`rounded-xl px-3 py-2 ${formData.is_published ? 'bg-[#8bc34a] text-white' : 'bg-amber-100 text-amber-800'}`}>
{formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'}
</span>
<span className="rounded-xl bg-gray-100 px-3 py-2 text-gray-500">Slug: {initialData.slug}</span>
<span className="rounded-xl bg-gray-100 px-3 py-2 text-gray-500">Slug: {facilitySlug || 'ikke satt ennå'}</span>
</p>
</div>
<div className="flex w-full flex-col gap-3 md:w-auto md:flex-row">
<button
onClick={handleDeleteFacility}
disabled={deletingFacility}
className="btn btn-lg btn-danger w-full md:w-auto disabled:opacity-50"
>
{deletingFacility ? "Sletter..." : "Slett anlegg"}
</button>
{!isCreateMode ? (
<button
onClick={handleDeleteFacility}
disabled={deletingFacility}
className="btn btn-lg btn-danger w-full md:w-auto disabled:opacity-50"
>
{deletingFacility ? "Sletter..." : "Slett anlegg"}
</button>
) : null}
<button
onClick={handleSave}
disabled={saving}
className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre endringer"}
{saving ? (isCreateMode ? "Oppretter..." : "Lagrer...") : (isCreateMode ? "Opprett anlegg" : "Lagre endringer")}
</button>
</div>
</div>
@ -801,8 +857,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
</div>
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</label>
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleChange('name', e.target.value)} />
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleNameChange(e.target.value)} />
</div>
{isCreateMode ? (
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Slug</label>
<input
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none"
value={facilitySlug}
onChange={e => {
setSlugTouched(true);
handleChange('slug', slugify(e.target.value));
}}
placeholder="f.eks. oslo-golfklubb"
/>
<p className="text-xs text-gray-500">Brukes i URL-en. Hvis du lar feltet stå tomt, foreslås slug automatisk fra navnet.</p>
</div>
) : null}
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Viktig beskjed (Kursiv intro-tekst)</label>
@ -932,7 +1003,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]">
<div className="aspect-[16/10]">
{getValue('image_url', 'text') ? (
<img src={getValue('image_url', 'text')} alt={`${initialData.name} hovedbilde`} className="h-full w-full object-cover" />
<img src={getValue('image_url', 'text')} alt={`${facilityName} hovedbilde`} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-white/70">
Ingen hovedbilde valgt
@ -978,7 +1049,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#f6f7f3]">
<div className="aspect-square max-w-[240px]">
{getValue('logo_url', 'text') ? (
<img src={getValue('logo_url', 'text')} alt={`${initialData.name} logo`} className="h-full w-full object-contain p-4" />
<img src={getValue('logo_url', 'text')} alt={`${facilityName} logo`} className="h-full w-full object-contain p-4" />
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-[#11280f]/45">
Ingen logo valgt
@ -1024,7 +1095,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="grid gap-4 lg:grid-cols-[220px,minmax(0,1fr)]">
<div className="overflow-hidden rounded-[1.25rem] border border-[#112015]/8 bg-[#112015]">
<div className="aspect-[4/3]">
<img src={url} alt={`${initialData.name} galleri ${index + 1}`} className="h-full w-full object-cover" />
<img src={url} alt={`${facilityName} galleri ${index + 1}`} className="h-full w-full object-cover" />
</div>
</div>
<div className="space-y-4">

View file

@ -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;
}

View file

@ -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({
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
<div className="w-full flex justify-center px-4 md:px-0">
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt={`Værvarsel for ${facility.name}`} loading="lazy" decoding="async" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
{weatherImg ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt={`Værvarsel for ${facility.name}`} loading="lazy" decoding="async" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
</div>
</section>
@ -624,11 +660,17 @@ export default function FacilityDetailView({
)}
{/* 7. VIDEO SEKSJON */}
{facility.video_url && (
{videoEmbedUrl && (
<section id="video" className="space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen />
<iframe
src={videoEmbedUrl}
title={`Video fra ${facility.name}`}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
</section>
)}