en mikrolagring før app
This commit is contained in:
parent
90f833e17b
commit
8bfd351582
5 changed files with 303 additions and 7 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
270
backend/import_social_golfamore_csv.py
Normal file
270
backend/import_social_golfamore_csv.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
from env_config import get_database_url
|
||||
|
||||
|
||||
DEFAULT_CSV_PATH = Path("/opt/teeoff/Regneark uten navn - Ark 1.csv")
|
||||
DB_URL = get_database_url()
|
||||
|
||||
SLUG_OVERRIDES = {
|
||||
"Bodø Golfpark": "salten-golfklubb-bodo-golfpark",
|
||||
"Hemsedal (IKKE ALPIN)": "hemsedal-golfklubb",
|
||||
"Randsfjorden": "land-golfklubb",
|
||||
"Romerike": "aurskog-golfpark",
|
||||
"Steinkjær": "steinkjer-golfklubb",
|
||||
"Tønsberg Re": "re-golfklubb",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FacilityMatch:
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
normalized_blob: str
|
||||
tokens: set[str]
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
source = (value or "").translate(
|
||||
str.maketrans(
|
||||
{
|
||||
"æ": "ae",
|
||||
"ø": "o",
|
||||
"å": "a",
|
||||
"Æ": "Ae",
|
||||
"Ø": "O",
|
||||
"Å": "A",
|
||||
}
|
||||
)
|
||||
)
|
||||
normalized = unicodedata.normalize("NFKD", source)
|
||||
ascii_text = normalized.encode("ascii", "ignore").decode("ascii").lower()
|
||||
ascii_text = re.sub(r"\([^)]*\)", " ", ascii_text)
|
||||
ascii_text = ascii_text.replace("&", " and ")
|
||||
ascii_text = re.sub(r"[^a-z0-9]+", " ", ascii_text)
|
||||
return re.sub(r"\s+", " ", ascii_text).strip()
|
||||
|
||||
|
||||
def tokenize(value: str) -> set[str]:
|
||||
return {token for token in normalize_text(value).split() if token}
|
||||
|
||||
|
||||
def clean_cell(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = value.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def build_social_links(facebook_url: str | None, instagram_url: str | None) -> list[dict[str, str]]:
|
||||
social_links: list[dict[str, str]] = []
|
||||
if facebook_url:
|
||||
social_links.append({"platform": "Facebook", "url": facebook_url})
|
||||
if instagram_url:
|
||||
social_links.append({"platform": "Instagram", "url": instagram_url})
|
||||
return social_links
|
||||
|
||||
|
||||
def build_golfamore_data(description: str | None) -> dict[str, str]:
|
||||
if not description:
|
||||
return {}
|
||||
return {"terms": description}
|
||||
|
||||
|
||||
def select_facility(row_name: str, facilities: list[FacilityMatch]) -> FacilityMatch:
|
||||
override_slug = SLUG_OVERRIDES.get(row_name)
|
||||
if override_slug:
|
||||
for facility in facilities:
|
||||
if facility.slug == override_slug:
|
||||
return facility
|
||||
raise ValueError(f"Fant ikke override-slug '{override_slug}' for '{row_name}'.")
|
||||
|
||||
query_tokens = tokenize(row_name)
|
||||
query_norm = normalize_text(row_name)
|
||||
if not query_tokens:
|
||||
raise ValueError(f"Tom eller ugyldig anleggsidentifikator: '{row_name}'.")
|
||||
|
||||
scored: list[tuple[int, FacilityMatch]] = []
|
||||
for facility in facilities:
|
||||
if not query_tokens.issubset(facility.tokens):
|
||||
continue
|
||||
|
||||
score = 0
|
||||
if query_norm == normalize_text(facility.slug):
|
||||
score += 100
|
||||
if query_norm == normalize_text(facility.name):
|
||||
score += 100
|
||||
if query_norm and query_norm in facility.normalized_blob:
|
||||
score += 25
|
||||
score += max(0, 20 - (len(facility.tokens) - len(query_tokens)))
|
||||
scored.append((score, facility))
|
||||
|
||||
if not scored:
|
||||
raise ValueError(f"Fant ingen facility-match for '{row_name}'.")
|
||||
|
||||
scored.sort(key=lambda item: (-item[0], item[1].name))
|
||||
best_score = scored[0][0]
|
||||
best_matches = [facility for score, facility in scored if score == best_score]
|
||||
|
||||
if len(best_matches) != 1:
|
||||
options = ", ".join(f"{facility.name} ({facility.slug})" for facility in best_matches)
|
||||
raise ValueError(f"Flertydig match for '{row_name}': {options}")
|
||||
|
||||
return best_matches[0]
|
||||
|
||||
|
||||
async def fetch_facilities(conn: asyncpg.Connection) -> list[FacilityMatch]:
|
||||
rows = await conn.fetch("SELECT id, slug, name FROM facilities")
|
||||
facilities: list[FacilityMatch] = []
|
||||
for row in rows:
|
||||
blob = f"{row['name']} {row['slug']}"
|
||||
facilities.append(
|
||||
FacilityMatch(
|
||||
id=row["id"],
|
||||
slug=row["slug"],
|
||||
name=row["name"],
|
||||
normalized_blob=normalize_text(blob),
|
||||
tokens=tokenize(blob),
|
||||
)
|
||||
)
|
||||
return facilities
|
||||
|
||||
|
||||
async def run_import(csv_path: Path, apply_changes: bool) -> None:
|
||||
if not csv_path.exists():
|
||||
raise FileNotFoundError(f"Fant ikke CSV-fil: {csv_path}")
|
||||
|
||||
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
rows = list(reader)
|
||||
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
try:
|
||||
facilities = await fetch_facilities(conn)
|
||||
updates: list[dict[str, object]] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for row in rows:
|
||||
row_name = clean_cell(row.get("Anlegg"))
|
||||
if not row_name:
|
||||
continue
|
||||
|
||||
facility = select_facility(row_name, facilities)
|
||||
facebook_url = clean_cell(row.get("Facebook"))
|
||||
instagram_url = clean_cell(row.get("Instagram"))
|
||||
golfamore_description = clean_cell(row.get("Golfamore beskrivelse"))
|
||||
golfamore_url = clean_cell(row.get("Golfamore url"))
|
||||
social_links = build_social_links(facebook_url, instagram_url)
|
||||
golfamore_data = build_golfamore_data(golfamore_description)
|
||||
|
||||
if facebook_url and "facebook.com" not in facebook_url.lower():
|
||||
warnings.append(f"{row_name}: Facebook-kolonnen peker ikke til facebook.com -> {facebook_url}")
|
||||
if instagram_url and "instagram.com" not in instagram_url.lower():
|
||||
warnings.append(f"{row_name}: Instagram-kolonnen peker ikke til instagram.com -> {instagram_url}")
|
||||
|
||||
updates.append(
|
||||
{
|
||||
"row_name": row_name,
|
||||
"facility_id": facility.id,
|
||||
"slug": facility.slug,
|
||||
"name": facility.name,
|
||||
"facebook_url": facebook_url,
|
||||
"instagram_url": instagram_url,
|
||||
"social_links": social_links,
|
||||
"golfamore": bool(golfamore_description or golfamore_url),
|
||||
"golfamore_url": golfamore_url,
|
||||
"golfamore_data": golfamore_data,
|
||||
}
|
||||
)
|
||||
|
||||
print(f"Klar til å oppdatere {len(updates)} anlegg fra {csv_path}.")
|
||||
for update in updates:
|
||||
print(f"- {update['row_name']} -> {update['name']} ({update['slug']})")
|
||||
|
||||
if warnings:
|
||||
print("\nAdvarsler:")
|
||||
for warning in warnings:
|
||||
print(f"- {warning}")
|
||||
|
||||
if not apply_changes:
|
||||
print("\nDry-run fullført. Ingen data ble skrevet.")
|
||||
return
|
||||
|
||||
async with conn.transaction():
|
||||
for update in updates:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE facilities
|
||||
SET
|
||||
facebook_url = $1,
|
||||
instagram_url = $2,
|
||||
social_links = $3::jsonb,
|
||||
golfamore = $4,
|
||||
golfamore_url = $5,
|
||||
golfamore_data = $6::jsonb
|
||||
WHERE id = $7
|
||||
""",
|
||||
update["facebook_url"],
|
||||
update["instagram_url"],
|
||||
json.dumps(update["social_links"]),
|
||||
update["golfamore"],
|
||||
update["golfamore_url"],
|
||||
json.dumps(update["golfamore_data"]),
|
||||
update["facility_id"],
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
WITH cleaned AS (
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(entry)
|
||||
FROM jsonb_array_elements(COALESCE(social_links, '[]'::jsonb)) AS entry
|
||||
WHERE NULLIF(BTRIM(entry->>'url'), '') IS NOT NULL
|
||||
AND NULLIF(BTRIM(entry->>'platform'), '') IS NOT NULL
|
||||
),
|
||||
'[]'::jsonb
|
||||
) AS social_links
|
||||
FROM facilities
|
||||
)
|
||||
UPDATE facilities AS target
|
||||
SET
|
||||
facebook_url = NULLIF(BTRIM(target.facebook_url), ''),
|
||||
instagram_url = NULLIF(BTRIM(target.instagram_url), ''),
|
||||
social_links = cleaned.social_links
|
||||
FROM cleaned
|
||||
WHERE target.id = cleaned.id
|
||||
"""
|
||||
)
|
||||
|
||||
print("\nImport fullført.")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Importer sosiale medier og Golfamore-felter fra CSV.")
|
||||
parser.add_argument("--csv", type=Path, default=DEFAULT_CSV_PATH, help="Sti til CSV-filen som skal importeres.")
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Skriver endringene til databasen. Uten dette kjøres bare dry-run.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = parse_args()
|
||||
asyncio.run(run_import(arguments.csv, arguments.apply))
|
||||
17
frontend/src/app/admin/[alias]/page.tsx
Normal file
17
frontend/src/app/admin/[alias]/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { notFound, redirect } from "next/navigation";
|
||||
import { resolveFacilityAlias } from "@/app/facilityAliases";
|
||||
|
||||
type AdminFacilityAliasPageProps = {
|
||||
params: Promise<{ alias: string }>;
|
||||
};
|
||||
|
||||
export default async function AdminFacilityAliasPage({ params }: AdminFacilityAliasPageProps) {
|
||||
const { alias } = await params;
|
||||
const facilitySlug = await resolveFacilityAlias(alias);
|
||||
|
||||
if (!facilitySlug) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
redirect(`/admin/rediger/${facilitySlug}`);
|
||||
}
|
||||
|
|
@ -198,7 +198,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
const golfamoreData = parseJson(facility.golfamore_data, {});
|
||||
const nsgData = parseJson(facility.nsg_data, {});
|
||||
const socialLinksRaw = parseJson(facility.social_links, []);
|
||||
const socialLinks = Array.isArray(socialLinksRaw) ? socialLinksRaw : [];
|
||||
const socialLinks = (Array.isArray(socialLinksRaw) ? socialLinksRaw : []).filter((social: any) => {
|
||||
const platform = typeof social?.platform === 'string' ? social.platform.trim() : '';
|
||||
const url = typeof social?.url === 'string' ? social.url.trim() : '';
|
||||
return Boolean(platform && url);
|
||||
});
|
||||
|
||||
const coopClubsRaw = parseJson(facility.cooperating_clubs, []);
|
||||
const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : [];
|
||||
|
|
@ -483,7 +487,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
href={facility.golfamore_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-black text-[#ff5722] transition-colors hover:underline"
|
||||
className="font-black !text-[#ff5722] visited:!text-[#ff5722] hover:!text-[#ff5722] hover:underline"
|
||||
>
|
||||
{golfamoreData.terms || golfamoreData.gyldighet || "Ja"}
|
||||
</a>
|
||||
|
|
@ -496,7 +500,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
<span className="text-gray-400">Seniorgolf (NSG):</span>
|
||||
<span className="text-right ml-4">
|
||||
{hasNSG && facility.nsg_url
|
||||
? <a href={facility.nsg_url} target="_blank" rel="noopener noreferrer" className="font-black text-[#ff5722] transition-colors hover:underline">Ja (Vis avtale)</a>
|
||||
? <a href={facility.nsg_url} target="_blank" rel="noopener noreferrer" className="font-black !text-[#ff5722] visited:!text-[#ff5722] hover:!text-[#ff5722] hover:underline">Ja (Vis avtale)</a>
|
||||
: (hasNSG ? <span className="text-[#ff5722] font-black">Ja</span> : "Nei")
|
||||
}
|
||||
</span>
|
||||
|
|
@ -511,7 +515,12 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="font-black text-[#11280f]">{pakke.navn || 'Golfpakke'}</p>
|
||||
{pakke.beskrivelse && <p className="mt-1 text-sm text-gray-500">{pakke.beskrivelse}</p>}
|
||||
{pakke.beskrivelse && (
|
||||
<div
|
||||
className="mt-1 text-sm leading-relaxed text-gray-500 [&_a]:font-bold [&_a]:text-[#ff5722] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#d53300] [&_em]:italic [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:font-bold [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeRichText(String(pakke.beskrivelse)) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
<span className="text-xs font-black uppercase tracking-widest text-[#7ca982]">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
const pageTitle = "Klubbnummer";
|
||||
const pageTitle = "Klubbnummer i Golfbox";
|
||||
const pageDescription =
|
||||
"Sorterbar oversikt over NGF-nummer og klubbnavn for norske golfanlegg på TeeOff.";
|
||||
|
||||
|
|
@ -62,8 +62,8 @@ export default async function ClubNumbersPage() {
|
|||
/>
|
||||
<InfoPageShell
|
||||
eyebrow="Klubbnummer"
|
||||
title="NGF-numre samlet ett sted"
|
||||
intro="Denne siden er bygget som et faktisk verktøy: sorterbar, søkbar og med direkte lenke tilbake til den respektive baneprofilen."
|
||||
title="Klubbnummer i Golfbox"
|
||||
intro="I booking-vinduet i Golfbox (eller Gimmie) er det ofte vanskelig å se hvilken klubb spillere er fra. Om du ønsker kan du da bruke denne tabellen som oppslagsverk."
|
||||
>
|
||||
<ClubNumbersTable facilities={facilities} />
|
||||
</InfoPageShell>
|
||||
|
|
|
|||
Loading…
Reference in a new issue