Før grensesnitt for å registrere nytt golfanlegg

This commit is contained in:
Erol Haagenrud 2026-04-25 07:45:34 +02:00
parent f17075da0f
commit fec5f4e8c6
5 changed files with 636 additions and 53 deletions

View file

@ -42,6 +42,7 @@ from scrape_jobs import (
list_scrape_jobs, list_scrape_jobs,
) )
from env_config import get_database_url, get_required_env 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 from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
# --- KONFIGURASJON --- # --- KONFIGURASJON ---
@ -597,6 +598,25 @@ class BulkVtgRequest(BaseModel):
approvals: List[VtgApproval] approvals: List[VtgApproval]
class VtgContentApproval(BaseModel):
facility_id: int
vtg_pris: int | None
vtg_beskrivelse: str | None
class BulkVtgContentRequest(BaseModel):
approvals: List[VtgContentApproval]
class VtgCoursesApproval(BaseModel):
facility_id: int
vtg_datoer: List[dict] | None
class BulkVtgCoursesRequest(BaseModel):
approvals: List[VtgCoursesApproval]
class BulkGolfpakkerRequest(BaseModel): class BulkGolfpakkerRequest(BaseModel):
approvals: List[GolfpakkerApproval] approvals: List[GolfpakkerApproval]
@ -675,7 +695,7 @@ def format_row(row):
for key in [ for key in [
'status_updated_at', 'created_at', 'slope_valid_until', 'status_updated_at', 'created_at', 'slope_valid_until',
'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at', 'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at',
'golfpakker_updated_at' 'golfpakker_updated_at', 'vtg_content_updated_at', 'vtg_courses_updated_at'
]: ]:
if isinstance(d.get(key), (date, datetime)): if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat() d[key] = d[key].isoformat()
@ -683,11 +703,12 @@ def format_row(row):
json_list_fields = [ json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee', 'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer', 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer',
'weather_forecast' 'weather_forecast', 'vtg_courses_draft'
] ]
json_dict_fields = [ json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'amenities', 'vtg', 'nsg_data', 'golfamore_data',
'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft', 'hole_par_counts' 'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft', 'hole_par_counts',
'vtg_content_draft'
] ]
for field in json_list_fields: for field in json_list_fields:
@ -719,6 +740,75 @@ def format_row(row):
return d return d
def prepare_vtg_content_draft_payload(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
payload: dict[str, Any] = {}
if "foreslatt_vtg_pris" in value:
payload["foreslatt_vtg_pris"] = value.get("foreslatt_vtg_pris")
if "foreslatt_vtg_beskrivelse" in value:
payload["foreslatt_vtg_beskrivelse"] = value.get("foreslatt_vtg_beskrivelse")
ai_reason = str(value.get("ai_begrunnelse") or "").strip()
if ai_reason:
payload["ai_begrunnelse"] = ai_reason
return payload
def prepare_vtg_course_draft_payload(value: Any) -> list[dict[str, Any]] | None:
if not isinstance(value, dict) or "foreslatt_vtg_datoer" not in value:
return None
return filter_upcoming_courses(value.get("foreslatt_vtg_datoer"))
async def replace_facility_vtg_courses(conn, facility_id: int, rows: Any) -> list[dict[str, Any]]:
normalized_rows = filter_upcoming_courses(rows)
await conn.execute("DELETE FROM facility_vtg_courses WHERE facility_id = $1", facility_id)
if normalized_rows:
await conn.executemany(
"""
INSERT INTO facility_vtg_courses (
facility_id,
display_label,
status,
start_date,
end_date,
sort_order
) VALUES ($1, $2, $3, $4, $5, $6)
""",
[
(
facility_id,
row.get("dato"),
row.get("status"),
row.get("start_date"),
row.get("end_date"),
int(row.get("sort_order") or index),
)
for index, row in enumerate(normalized_rows)
],
)
legacy_rows = json.dumps(
[
{
"dato": row.get("dato"),
"status": row.get("status"),
"start_date": row.get("start_date"),
"end_date": row.get("end_date"),
}
for row in normalized_rows
]
)
await conn.execute(
"UPDATE facilities SET vtg_datoer = $1::jsonb WHERE id = $2",
legacy_rows,
facility_id,
)
return normalized_rows
def format_place_page_row(row): def format_place_page_row(row):
if row is None: if row is None:
return None return None
@ -1450,10 +1540,109 @@ async def ensure_facility_columns(conn):
ADD COLUMN IF NOT EXISTS golfamore_url TEXT, ADD COLUMN IF NOT EXISTS golfamore_url TEXT,
ADD COLUMN IF NOT EXISTS golfpakker_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_url TEXT,
ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB, ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB,
ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS vtg_content_draft JSONB,
ADD COLUMN IF NOT EXISTS vtg_courses_draft JSONB,
ADD COLUMN IF NOT EXISTS vtg_content_updated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS vtg_courses_updated_at TIMESTAMPTZ
""") """)
async def ensure_vtg_course_tables(conn):
await conn.execute("""
CREATE TABLE IF NOT EXISTS facility_vtg_courses (
id SERIAL PRIMARY KEY,
facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
display_label TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Ledig',
start_date DATE,
end_date DATE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_facility_vtg_courses_facility_id
ON facility_vtg_courses (facility_id)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_facility_vtg_courses_start_date
ON facility_vtg_courses (start_date)
""")
facility_columns = await get_table_columns(conn, "facilities")
if "vtg_datoer" in facility_columns:
rows = await conn.fetch(
"""
SELECT f.id, f.vtg_datoer
FROM facilities f
WHERE COALESCE(jsonb_array_length(CASE WHEN jsonb_typeof(f.vtg_datoer) = 'array' THEN f.vtg_datoer ELSE '[]'::jsonb END), 0) > 0
AND NOT EXISTS (
SELECT 1
FROM facility_vtg_courses c
WHERE c.facility_id = f.id
)
"""
)
for row in rows:
await replace_facility_vtg_courses(conn, int(row["id"]), row["vtg_datoer"])
if "vtg_content_draft" in facility_columns and "vtg_courses_draft" in facility_columns and "vtg_draft" in facility_columns:
draft_rows = await conn.fetch(
"""
SELECT id, vtg_draft, vtg_content_draft, vtg_courses_draft
FROM facilities
WHERE vtg_draft IS NOT NULL
AND vtg_draft::text != '{}'
"""
)
for row in draft_rows:
legacy_draft = row["vtg_draft"]
content_draft = row["vtg_content_draft"]
courses_draft = row["vtg_courses_draft"]
if isinstance(legacy_draft, str):
try:
legacy_draft = json.loads(legacy_draft)
except json.JSONDecodeError:
legacy_draft = {}
if isinstance(content_draft, str):
try:
content_draft = json.loads(content_draft)
except json.JSONDecodeError:
content_draft = None
if isinstance(courses_draft, str):
try:
courses_draft = json.loads(courses_draft)
except json.JSONDecodeError:
courses_draft = None
next_content_draft = (
content_draft
if isinstance(content_draft, dict) and content_draft
else prepare_vtg_content_draft_payload(legacy_draft)
)
next_courses_draft = (
courses_draft
if isinstance(courses_draft, list)
else prepare_vtg_course_draft_payload(legacy_draft)
)
if next_content_draft != content_draft or next_courses_draft != courses_draft:
await conn.execute(
"""
UPDATE facilities
SET vtg_content_draft = $1::jsonb,
vtg_courses_draft = $2::jsonb
WHERE id = $3
""",
json.dumps(next_content_draft),
json.dumps(next_courses_draft if next_courses_draft is not None else []),
int(row["id"]),
)
async def ensure_place_pages_table(conn): async def ensure_place_pages_table(conn):
await conn.execute(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS place_pages ( CREATE TABLE IF NOT EXISTS place_pages (
@ -1631,6 +1820,7 @@ async def lifespan(app: FastAPI):
) )
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
await ensure_facility_columns(conn) await ensure_facility_columns(conn)
await ensure_vtg_course_tables(conn)
await ensure_place_pages_table(conn) await ensure_place_pages_table(conn)
await ensure_articles_table(conn) await ensure_articles_table(conn)
await ensure_public_user_tables(conn) await ensure_public_user_tables(conn)
@ -3143,7 +3333,8 @@ async def update_facility_full(facility_id: int, request: Request):
'guest_requirements', 'scrape_method', 'scrape_status_url', 'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at', 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
'vtg_updated_at', 'vtg_draft', 'footnote_updated_at', 'is_published', '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' 'golfpakker_draft', 'golfpakker_updated_at'
] ]
@ -3152,7 +3343,9 @@ async def update_facility_full(facility_id: int, request: Request):
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at' 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at'
} }
vtg_fields = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer', 'vtg_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()) changed_field_names = set(update_data.keys())
facility_slug = "" facility_slug = ""
@ -3185,11 +3378,23 @@ async def update_facility_full(facility_id: int, request: Request):
'membership_updated_at', 'membership_updated_at',
'greenfee_updated_at', 'greenfee_updated_at',
'vtg_updated_at', 'vtg_updated_at',
'vtg_content_updated_at',
'vtg_courses_updated_at',
'status_updated_at', 'status_updated_at',
'footnote_updated_at', 'footnote_updated_at',
'golfpakker_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): for i, (k, v) in enumerate(update_data.items(), 1):
if isinstance(v, (dict, list)): if isinstance(v, (dict, list)):
set_clauses.append(f"{k} = ${i}::jsonb") set_clauses.append(f"{k} = ${i}::jsonb")
@ -3216,6 +3421,9 @@ async def update_facility_full(facility_id: int, request: Request):
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}" query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
await conn.execute(query, *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) # 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
if 'courses' in data: if 'courses' in data:
submitted_courses = [course for course in (data.get('courses') or []) if course] submitted_courses = [course for course in (data.get('courses') or []) if course]
@ -3602,34 +3810,123 @@ async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, http_request:
@app.get("/api/admin/vtg/drafts") @app.get("/api/admin/vtg/drafts")
async def get_vtg_drafts(): async def get_vtg_drafts():
"""Henter alle anlegg som har et ventende VTG-forslag.""" """Henter alle anlegg som har ventende VTG-forslag for innhold eller kurs."""
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
rows = await conn.fetch(""" rows = await conn.fetch("""
SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft SELECT
id,
name,
slug,
vtg_lenke,
vtg_pris,
vtg_beskrivelse,
vtg_datoer,
vtg_content_draft,
vtg_courses_draft,
CASE
WHEN vtg_content_draft IS NOT NULL AND vtg_content_draft::text != '{}'
THEN TRUE
ELSE FALSE
END AS has_vtg_content_draft,
CASE
WHEN vtg_courses_draft IS NOT NULL
THEN TRUE
ELSE FALSE
END AS has_vtg_courses_draft
FROM facilities FROM facilities
WHERE vtg_draft IS NOT NULL WHERE (
AND vtg_draft::text != '{}' vtg_content_draft IS NOT NULL AND vtg_content_draft::text != '{}'
) OR vtg_courses_draft IS NOT NULL
ORDER BY name ASC ORDER BY name ASC
""") """)
return [format_row(row) for row in rows] return [format_row(row) for row in rows]
@app.post("/api/admin/vtg/approve-bulk") @app.post("/api/admin/vtg/approve-content-bulk")
async def approve_vtg_bulk(request: BulkVtgRequest): async def approve_vtg_content_bulk(request: BulkVtgContentRequest):
"""Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet."""
facility_ids = [approval.facility_id for approval in request.approvals] facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
for approval in request.approvals: for approval in request.approvals:
datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]' await conn.execute(
await conn.execute(""" """
UPDATE facilities UPDATE facilities
SET vtg_pris = $1, SET vtg_pris = $1,
vtg_beskrivelse = $2, vtg_beskrivelse = $2,
vtg_datoer = $3::jsonb, vtg_content_updated_at = NOW(),
vtg_updated_at = NOW(), vtg_updated_at = NOW(),
vtg_content_draft = NULL,
vtg_draft = CASE
WHEN vtg_courses_draft IS NULL THEN NULL
ELSE vtg_draft
END
WHERE id = $3
""",
approval.vtg_pris,
approval.vtg_beskrivelse,
approval.facility_id,
)
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
reason="vtg content bulk approval",
)
return {"status": "success"}
@app.post("/api/admin/vtg/approve-courses-bulk")
async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await replace_facility_vtg_courses(conn, approval.facility_id, approval.vtg_datoer)
await conn.execute(
"""
UPDATE facilities
SET vtg_courses_updated_at = NOW(),
vtg_updated_at = NOW(),
vtg_courses_draft = NULL,
vtg_draft = CASE
WHEN vtg_content_draft IS NULL THEN NULL
ELSE vtg_draft
END
WHERE id = $1
""",
approval.facility_id,
)
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
reason="vtg courses bulk approval",
)
return {"status": "success"}
@app.post("/api/admin/vtg/approve-bulk")
async def approve_vtg_bulk(request: BulkVtgRequest):
"""Kompatibilitets-endepunkt som godkjenner både innhold og kurs."""
facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await replace_facility_vtg_courses(conn, approval.facility_id, approval.vtg_datoer)
await conn.execute(
"""
UPDATE facilities
SET vtg_pris = $1,
vtg_beskrivelse = $2,
vtg_content_updated_at = NOW(),
vtg_courses_updated_at = NOW(),
vtg_updated_at = NOW(),
vtg_content_draft = NULL,
vtg_courses_draft = NULL,
vtg_draft = NULL vtg_draft = NULL
WHERE id = $4 WHERE id = $3
""", approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id) """,
approval.vtg_pris,
approval.vtg_beskrivelse,
approval.facility_id,
)
facility_slugs = await fetch_facility_slugs(conn, facility_ids) facility_slugs = await fetch_facility_slugs(conn, facility_ids)
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]), collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),

View file

@ -17,6 +17,7 @@ import google.generativeai as genai
from dotenv import load_dotenv from dotenv import load_dotenv
from env_config import get_database_url from env_config import get_database_url
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
from vtg_courses import filter_upcoming_courses
load_dotenv() load_dotenv()
@ -209,14 +210,22 @@ async def run_vtg_scraper(facility_ids=None, progress_callback: ProgressCallback
continue continue
analyzed_count += 1 analyzed_count += 1
found_dates = len(draft_data.get('foreslatt_vtg_datoer', [])) normalized_course_draft = filter_upcoming_courses(draft_data.get('foreslatt_vtg_datoer', []))
content_draft = {
"foreslatt_vtg_pris": draft_data.get("foreslatt_vtg_pris"),
"foreslatt_vtg_beskrivelse": draft_data.get("foreslatt_vtg_beskrivelse"),
"ai_begrunnelse": draft_data.get("ai_begrunnelse"),
}
found_dates = len(normalized_course_draft)
print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {found_dates} datoer.") print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {found_dates} datoer.")
await conn.execute(""" await conn.execute("""
UPDATE facilities UPDATE facilities
SET vtg_draft = $1::jsonb SET vtg_draft = $1::jsonb,
WHERE id = $2 vtg_content_draft = $2::jsonb,
""", json.dumps(draft_data), fac_id) vtg_courses_draft = $3::jsonb
WHERE id = $4
""", json.dumps(draft_data), json.dumps(content_draft), json.dumps(normalized_course_draft), fac_id)
print(" 💾 VTG-utkast lagret i databasen!") print(" 💾 VTG-utkast lagret i databasen!")
saved_count += 1 saved_count += 1

202
backend/vtg_courses.py Normal file
View file

@ -0,0 +1,202 @@
import re
from datetime import date, datetime
from typing import Any
MONTH_MAP: dict[str, int] = {
"januar": 1,
"jan": 1,
"februar": 2,
"feb": 2,
"mars": 3,
"mar": 3,
"april": 4,
"apr": 4,
"mai": 5,
"juni": 6,
"jun": 6,
"juli": 7,
"jul": 7,
"august": 8,
"aug": 8,
"september": 9,
"sep": 9,
"sept": 9,
"oktober": 10,
"okt": 10,
"november": 11,
"nov": 11,
"desember": 12,
"des": 12,
}
def normalize_whitespace(value: str) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def _to_date(year: int, month: int, day: int) -> date | None:
try:
return date(year, month, day)
except ValueError:
return None
def _infer_year(month: int, day: int, explicit_year: int | None, today: date) -> int:
if explicit_year:
return explicit_year
candidate = _to_date(today.year, month, day)
if candidate and candidate < today.replace(day=max(1, min(today.day, 28))):
if (today - candidate).days > 7:
return today.year + 1
return today.year
def _parse_numeric_date(raw: str) -> date | None:
match = re.search(r"\b(\d{1,2})[./](\d{1,2})[./](\d{2,4})\b", raw)
if not match:
return None
day = int(match.group(1))
month = int(match.group(2))
year = int(match.group(3))
if year < 100:
year += 2000
return _to_date(year, month, day)
def _parse_textual_dates(raw: str, today: date) -> list[date]:
results: list[date] = []
pattern = re.compile(
r"\b(\d{1,2})\.?\s*(" + "|".join(sorted(MONTH_MAP.keys(), key=len, reverse=True)) + r")\b(?:\s+(20\d{2}))?",
re.IGNORECASE,
)
for match in pattern.finditer(raw):
day = int(match.group(1))
month = MONTH_MAP.get(match.group(2).lower())
if not month:
continue
explicit_year = int(match.group(3)) if match.group(3) else None
year = _infer_year(month, day, explicit_year, today)
candidate = _to_date(year, month, day)
if candidate:
results.append(candidate)
return results
def parse_course_date_range(raw: str, today: date | None = None) -> tuple[date | None, date | None]:
reference_today = today or date.today()
normalized = normalize_whitespace(raw).lower()
if not normalized:
return None, None
iso_candidate = None
try:
iso_candidate = datetime.fromisoformat(normalized).date()
except ValueError:
iso_candidate = None
if iso_candidate:
return iso_candidate, iso_candidate
numeric_dates = re.findall(r"\b\d{1,2}[./]\d{1,2}[./]\d{2,4}\b", normalized)
if len(numeric_dates) >= 2:
start = _parse_numeric_date(numeric_dates[0])
end = _parse_numeric_date(numeric_dates[1])
return start, end or start
if len(numeric_dates) == 1:
single = _parse_numeric_date(numeric_dates[0])
return single, single
range_match = re.search(
r"\b(\d{1,2})\s*\.?\s*(?:-||—|til)\s*(\d{1,2})\.?\s*(" + "|".join(sorted(MONTH_MAP.keys(), key=len, reverse=True)) + r")\b(?:\s+(20\d{2}))?",
normalized,
re.IGNORECASE,
)
if range_match:
start_day = int(range_match.group(1))
end_day = int(range_match.group(2))
month = MONTH_MAP.get(range_match.group(3).lower())
explicit_year = int(range_match.group(4)) if range_match.group(4) else None
if month:
year = _infer_year(month, end_day, explicit_year, reference_today)
start = _to_date(year, month, start_day)
end = _to_date(year, month, end_day)
return start, end or start
textual_dates = _parse_textual_dates(normalized, reference_today)
if len(textual_dates) >= 2:
return textual_dates[0], textual_dates[1]
if len(textual_dates) == 1:
return textual_dates[0], textual_dates[0]
return None, None
def normalize_vtg_course_rows(rows: Any) -> list[dict[str, Any]]:
if not isinstance(rows, list):
return []
normalized_rows: list[dict[str, Any]] = []
for index, row in enumerate(rows):
if not isinstance(row, dict):
continue
display_label = normalize_whitespace(str(row.get("dato") or row.get("display_label") or ""))
if not display_label:
continue
status = normalize_whitespace(str(row.get("status") or "Ledig")) or "Ledig"
explicit_start = row.get("start_date")
explicit_end = row.get("end_date")
if explicit_start:
try:
start_date = datetime.fromisoformat(str(explicit_start)).date()
except ValueError:
start_date = None
else:
start_date = None
if explicit_end:
try:
end_date = datetime.fromisoformat(str(explicit_end)).date()
except ValueError:
end_date = None
else:
end_date = None
if not start_date and not end_date:
start_date, end_date = parse_course_date_range(display_label)
normalized_rows.append(
{
"dato": display_label,
"status": status,
"start_date": start_date.isoformat() if start_date else None,
"end_date": end_date.isoformat() if end_date else None,
"sort_order": index,
}
)
normalized_rows.sort(
key=lambda row: (
row.get("start_date") or row.get("end_date") or "9999-12-31",
int(row.get("sort_order") or 0),
row.get("dato") or "",
)
)
return normalized_rows
def is_upcoming_course(row: dict[str, Any], today: date | None = None) -> bool:
reference_today = today or date.today()
end_value = row.get("end_date") or row.get("start_date")
if not end_value:
return True
try:
end_date = datetime.fromisoformat(str(end_value)).date()
except ValueError:
return True
return end_date >= reference_today
def filter_upcoming_courses(rows: Any) -> list[dict[str, Any]]:
normalized_rows = normalize_vtg_course_rows(rows)
return [row for row in normalized_rows if is_upcoming_course(row)]

View file

@ -8,13 +8,17 @@ import Link from 'next/link';
type VtgDateRow = { type VtgDateRow = {
dato?: string; dato?: string;
status?: string; status?: string;
start_date?: string | null;
end_date?: string | null;
}; };
const normalizeDateRows = (value: any): VtgDateRow[] => { const normalizeDateRows = (value: any): VtgDateRow[] => {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value.map((row) => ({ return value.map((row) => ({
dato: typeof row?.dato === 'string' ? row.dato : '', dato: typeof row?.dato === 'string' ? row.dato : '',
status: typeof row?.status === 'string' ? row.status : 'Ledig' status: typeof row?.status === 'string' ? row.status : 'Ledig',
start_date: typeof row?.start_date === 'string' ? row.start_date : null,
end_date: typeof row?.end_date === 'string' ? row.end_date : null,
})); }));
}; };
@ -37,6 +41,18 @@ const formatPriceLabel = (value: any) => {
return parsed === null ? 'Ingen pris registrert' : `${parsed} kr`; return parsed === null ? 'Ingen pris registrert' : `${parsed} kr`;
}; };
const hasContentDraftRecord = (draft: any) => Boolean(draft?.has_vtg_content_draft);
const hasCourseDraftRecord = (draft: any) => Boolean(draft?.has_vtg_courses_draft);
const hasContentDraftChanges = (draft: any) => (
hasContentDraftRecord(draft) && (
priceValue(draft?.vtg_pris) !== priceValue(draft?.edit_pris) ||
textValue(draft?.vtg_beskrivelse) !== textValue(draft?.edit_beskrivelse)
)
);
const hasCourseDraftChanges = (draft: any) => (
hasCourseDraftRecord(draft) && !datesAreEqual(draft?.vtg_datoer, draft?.edit_datoer)
);
function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) { function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) {
const normalizedDates = normalizeDateRows(dates); const normalizedDates = normalizeDateRows(dates);
@ -68,20 +84,34 @@ export default function VtgWasher() {
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
const editableDrafts = data.map((f: any) => { const editableDrafts = data.map((f: any) => {
let parsedDraft = f.vtg_draft; let parsedContentDraft = f.vtg_content_draft;
if (typeof parsedDraft === 'string') { if (typeof parsedContentDraft === 'string') {
try { parsedDraft = JSON.parse(parsedDraft); } try { parsedContentDraft = JSON.parse(parsedContentDraft); }
catch (e) { console.error("Kunne ikke parse JSON", e); } catch (e) { console.error("Kunne ikke parse JSON", e); }
} }
const currentDates = normalizeDateRows(f.vtg_datoer);
const parsedCourseDraft = normalizeDateRows(f.vtg_courses_draft);
const contentDraftActive = Boolean(f.has_vtg_content_draft);
const courseDraftActive = Boolean(f.has_vtg_courses_draft);
return { return {
...f, ...f,
vtg_draft: parsedDraft, vtg_content_draft: parsedContentDraft,
edit_pris: parsedDraft?.foreslatt_vtg_pris || f.vtg_pris || '', vtg_datoer: currentDates,
edit_beskrivelse: parsedDraft?.foreslatt_vtg_beskrivelse || f.vtg_beskrivelse || '', vtg_courses_draft: parsedCourseDraft,
edit_datoer: parsedDraft?.foreslatt_vtg_datoer || [] has_vtg_content_draft: contentDraftActive,
has_vtg_courses_draft: courseDraftActive,
edit_pris: contentDraftActive ? (parsedContentDraft?.foreslatt_vtg_pris ?? f.vtg_pris ?? '') : (f.vtg_pris ?? ''),
edit_beskrivelse: contentDraftActive ? (parsedContentDraft?.foreslatt_vtg_beskrivelse ?? f.vtg_beskrivelse ?? '') : (f.vtg_beskrivelse ?? ''),
edit_datoer: courseDraftActive ? parsedCourseDraft : currentDates
}; };
}); }).filter((draft: any) => (
(draft.has_vtg_content_draft && (
priceValue(draft.vtg_pris) !== priceValue(draft.edit_pris) ||
textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse)
)) ||
(draft.has_vtg_courses_draft && !datesAreEqual(draft.vtg_datoer, draft.edit_datoer))
));
setDrafts(editableDrafts); setDrafts(editableDrafts);
setLoading(false); setLoading(false);
}) })
@ -135,25 +165,24 @@ export default function VtgWasher() {
})); }));
}; };
const handleApprove = async () => { const handleApproveContent = async () => {
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({ const toApprove = drafts.filter(d => selectedIds.includes(d.id) && hasContentDraftChanges(d)).map(d => ({
facility_id: d.id, facility_id: d.id,
vtg_pris: Number(d.edit_pris) || null, vtg_pris: Number(d.edit_pris) || null,
vtg_beskrivelse: d.edit_beskrivelse, vtg_beskrivelse: d.edit_beskrivelse,
vtg_datoer: d.edit_datoer
})); }));
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne."); if (toApprove.length === 0) return alert("Velg minst ett anlegg med ventende VTG-innhold.");
setSaving(true); setSaving(true);
try { try {
const res = await adminFetch(`${API_URL}/admin/vtg/approve-bulk`, { const res = await adminFetch(`${API_URL}/admin/vtg/approve-content-bulk`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvals: toApprove }) body: JSON.stringify({ approvals: toApprove })
}); });
if (res.ok) { if (res.ok) {
alert(`${toApprove.length} anlegg oppdatert!`); alert(`${toApprove.length} VTG-innhold oppdatert!`);
setSelectedIds([]); setSelectedIds([]);
fetchDrafts(); fetchDrafts();
} else { } else {
@ -165,6 +194,37 @@ export default function VtgWasher() {
setSaving(false); setSaving(false);
}; };
const handleApproveCourses = async () => {
const toApprove = drafts.filter(d => selectedIds.includes(d.id) && hasCourseDraftChanges(d)).map(d => ({
facility_id: d.id,
vtg_datoer: d.edit_datoer
}));
if (toApprove.length === 0) return alert("Velg minst ett anlegg med ventende kommende kurs.");
setSaving(true);
try {
const res = await adminFetch(`${API_URL}/admin/vtg/approve-courses-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvals: toApprove })
});
if (res.ok) {
alert(`${toApprove.length} kurslister oppdatert!`);
setSelectedIds([]);
fetchDrafts();
} else {
alert("Noe gikk galt under lagring.");
}
} catch (e) {
alert("Nettverksfeil");
}
setSaving(false);
};
const selectedContentCount = drafts.filter(d => selectedIds.includes(d.id) && hasContentDraftChanges(d)).length;
const selectedCourseCount = drafts.filter(d => selectedIds.includes(d.id) && hasCourseDraftChanges(d)).length;
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster VTG-utkast...</div>; if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster VTG-utkast...</div>;
return ( return (
@ -175,11 +235,16 @@ export default function VtgWasher() {
<div> <div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link> <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">VTG-Vaskeriet</h1> <h1 className="text-4xl font-black">VTG-Vaskeriet</h1>
<p className="text-sm text-gray-600 mt-2"> gjennom og godkjenn kursinformasjon for Veien til Golf.</p> <p className="text-sm text-gray-600 mt-2"> gjennom og godkjenn stabilt VTG-innhold og kommende kurs hver for seg.</p>
</div>
<div className="flex w-full flex-col gap-3 md:w-auto md:flex-row">
<button onClick={handleApproveContent} disabled={saving || selectedContentCount === 0} className="btn btn-lg btn-secondary w-full md:w-auto disabled:opacity-50">
{saving ? 'Lagrer...' : `Godkjenn innhold (${selectedContentCount})`}
</button>
<button onClick={handleApproveCourses} disabled={saving || selectedCourseCount === 0} className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50">
{saving ? 'Lagrer...' : `Godkjenn kurs (${selectedCourseCount})`}
</button>
</div> </div>
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50">
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>
</div> </div>
{drafts.length === 0 ? ( {drafts.length === 0 ? (
@ -200,6 +265,7 @@ export default function VtgWasher() {
const priceChanged = priceValue(draft.vtg_pris) !== priceValue(draft.edit_pris); const priceChanged = priceValue(draft.vtg_pris) !== priceValue(draft.edit_pris);
const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse); const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse);
const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer); const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer);
const contentChangedCount = [priceChanged, descriptionChanged].filter(Boolean).length;
const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length; const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length;
return ( return (
@ -213,23 +279,25 @@ export default function VtgWasher() {
<span className={`rounded-full px-3 py-1 text-[11px] font-black uppercase tracking-widest ${changedCount > 0 ? 'bg-amber-100 text-amber-900' : 'bg-gray-100 text-gray-500'}`}> <span className={`rounded-full px-3 py-1 text-[11px] font-black uppercase tracking-widest ${changedCount > 0 ? 'bg-amber-100 text-amber-900' : 'bg-gray-100 text-gray-500'}`}>
{changedCount > 0 ? `${changedCount} felt endret` : 'Ingen reell endring'} {changedCount > 0 ? `${changedCount} felt endret` : 'Ingen reell endring'}
</span> </span>
{hasContentDraftChanges(draft) && <span className="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-slate-700">Innholdsdraft</span>}
{hasCourseDraftChanges(draft) && <span className="rounded-full bg-sky-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-sky-800">Kursdraft</span>}
{priceChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Pris</span>} {priceChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Pris</span>}
{descriptionChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Beskrivelse</span>} {descriptionChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Beskrivelse</span>}
{datesChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Kursdatoer</span>} {datesChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Kommende kurs</span>}
</div> </div>
</div> </div>
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside </a> <a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside </a>
</div> </div>
{draft.vtg_draft?.ai_begrunnelse && ( {draft.vtg_content_draft?.ai_begrunnelse && (
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100"> <div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
<strong>🤖 AI Begrunnelse:</strong> {draft.vtg_draft.ai_begrunnelse} <strong>🤖 AI Begrunnelse:</strong> {draft.vtg_content_draft.ai_begrunnelse}
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2"> <div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Pris & Beskrivelse</h4> <h4 className="text-xs font-black uppercase tracking-widest text-green-600">Stabilt innhold</h4>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4"> <div className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div> <div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
@ -246,11 +314,11 @@ export default function VtgWasher() {
</div> </div>
</div> </div>
</div> </div>
<div className={`rounded-2xl border p-4 ${priceChanged || descriptionChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}> <div className={`rounded-2xl border p-4 ${contentChangedCount > 0 ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div> <div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${priceChanged || descriptionChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}> <span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${contentChangedCount > 0 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
{priceChanged || descriptionChanged ? 'Har endringer' : 'Lik dagens verdi'} {contentChangedCount > 0 ? 'Har endringer' : 'Lik dagens verdi'}
</span> </span>
</div> </div>
<div> <div>
@ -266,7 +334,7 @@ export default function VtgWasher() {
</div> </div>
<div> <div>
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kursdatoer</h4> <h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kommende kurs</h4>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4"> <div className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div> <div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
@ -276,12 +344,12 @@ export default function VtgWasher() {
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div> <div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${datesChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}> <span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${datesChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
{datesChanged ? 'Har endringer' : 'Lik dagens datoer'} {datesChanged ? 'Har endringer' : 'Lik dagens kurs'}
</span> </span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{draft.edit_datoer.length === 0 ? ( {draft.edit_datoer.length === 0 ? (
<div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Fant ingen spesifikke kursdatoer.</div> <div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Ingen kommende kurs funnet i forslaget.</div>
) : ( ) : (
draft.edit_datoer.map((row: any, idx: number) => ( draft.edit_datoer.map((row: any, idx: number) => (
<div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center"> <div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center">

View file

@ -209,7 +209,14 @@ export default function FacilityDetailView({
// Pris og kurs-arrays // Pris og kurs-arrays
const greenfeeRaw = parseJson(facility.greenfee, []); const greenfeeRaw = parseJson(facility.greenfee, []);
const golfpakkerRaw = parseJson(facility.golfpakker, []); const golfpakkerRaw = parseJson(facility.golfpakker, []);
const vtgDatoer = parseJson(facility.vtg_datoer, []); const todayKey = new Date().toISOString().slice(0, 10);
const vtgDatoer = parseJson(facility.vtg_datoer, []).filter((row: any) => {
const endDate = typeof row?.end_date === 'string' ? row.end_date : '';
const startDate = typeof row?.start_date === 'string' ? row.start_date : '';
const comparable = endDate || startDate;
if (!comparable) return true;
return comparable >= todayKey;
});
const golfamoreData = parseJson(facility.golfamore_data, {}); const golfamoreData = parseJson(facility.golfamore_data, {});
const nsgData = parseJson(facility.nsg_data, {}); const nsgData = parseJson(facility.nsg_data, {});