Før hastighetsøkning
This commit is contained in:
parent
fec5f4e8c6
commit
dc7ed19f02
7 changed files with 704 additions and 282 deletions
137
backend/course_status_history.py
Normal file
137
backend/course_status_history.py
Normal 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
|
||||
]
|
||||
608
backend/main.py
608
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."""
|
||||
|
|
|
|||
31
frontend/src/app/admin/nytt-anlegg/page.tsx
Normal file
31
frontend/src/app/admin/nytt-anlegg/page.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue