Etter mange bugfix etter første dag i drift

This commit is contained in:
Erol Haagenrud 2026-04-17 22:46:57 +02:00
parent 700f5aa08d
commit d9c747e83d
361 changed files with 2729 additions and 805 deletions

View file

@ -68,6 +68,7 @@ SMTP_PORT = os.getenv("SMTP_PORT", "").strip()
SMTP_USER = os.getenv("SMTP_USER", "").strip() SMTP_USER = os.getenv("SMTP_USER", "").strip()
SMTP_PASS = os.getenv("SMTP_PASS", "").strip() SMTP_PASS = os.getenv("SMTP_PASS", "").strip()
PUBLIC_FROM_EMAIL = os.getenv("PUBLIC_FROM_EMAIL", SMTP_USER).strip() PUBLIC_FROM_EMAIL = os.getenv("PUBLIC_FROM_EMAIL", SMTP_USER).strip()
CONTACT_FORM_TO_EMAIL = os.getenv("CONTACT_FORM_TO_EMAIL", "teeoff@teeoff.no").strip()
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
@ -84,6 +85,9 @@ ADMIN_SESSION_MAX_AGE_SECONDS = get_int_env("ADMIN_SESSION_MAX_AGE_SECONDS", 60
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS = get_int_env("ADMIN_REMEMBER_ME_MAX_AGE_SECONDS", 60 * 60 * 24 * 30) ADMIN_REMEMBER_ME_MAX_AGE_SECONDS = get_int_env("ADMIN_REMEMBER_ME_MAX_AGE_SECONDS", 60 * 60 * 24 * 30)
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20) PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20)
PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60) PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60)
CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS = get_int_env("CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS", 60 * 60)
CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS = get_int_env("CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS", 3)
CONTACT_FORM_MIN_FILL_SECONDS = get_int_env("CONTACT_FORM_MIN_FILL_SECONDS", 5)
def resolve_imported_meninger_path() -> Path: def resolve_imported_meninger_path() -> Path:
@ -122,6 +126,10 @@ def is_magic_link_configured() -> bool:
return bool(SMTP_SERVER and SMTP_PORT and SMTP_USER and SMTP_PASS and PUBLIC_FROM_EMAIL) return bool(SMTP_SERVER and SMTP_PORT and SMTP_USER and SMTP_PASS and PUBLIC_FROM_EMAIL)
def is_contact_form_configured() -> bool:
return is_magic_link_configured() and bool(CONTACT_FORM_TO_EMAIL)
def get_public_auth_config() -> dict[str, Any]: def get_public_auth_config() -> dict[str, Any]:
google_enabled = is_google_login_configured() google_enabled = is_google_login_configured()
magic_link_enabled = is_magic_link_configured() magic_link_enabled = is_magic_link_configured()
@ -256,6 +264,40 @@ async def send_magic_link_email(email_address: str, login_url: str) -> None:
await asyncio.to_thread(_send) await asyncio.to_thread(_send)
async def send_contact_form_email(
*,
sender_name: str,
sender_email: str,
topic: str,
message: str,
ip_hash: str | None,
) -> None:
subject = f"[TeeOff Kontakt] {topic}"
body = (
"Ny melding fra kontaktskjemaet på TeeOff.no\n\n"
f"Navn: {sender_name}\n"
f"E-post: {sender_email}\n"
f"Emne: {topic}\n"
f"IP-hash: {ip_hash or 'ukjent'}\n\n"
"Melding:\n"
f"{message.strip()}\n"
)
def _send() -> None:
mail = EmailMessage()
mail["From"] = PUBLIC_FROM_EMAIL
mail["To"] = CONTACT_FORM_TO_EMAIL
mail["Reply-To"] = sender_email
mail["Subject"] = subject
mail.set_content(body)
with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server:
server.login(SMTP_USER, SMTP_PASS)
server.send_message(mail)
await asyncio.to_thread(_send)
async def validate_admin_session_token(token: str) -> str: async def validate_admin_session_token(token: str) -> str:
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
@ -338,6 +380,8 @@ class ArticleUpsertRequest(BaseModel):
author_name: Optional[str] = None author_name: Optional[str] = None
status: Optional[str] = "draft" status: Optional[str] = "draft"
hero_images: Optional[List[dict[str, Any]]] = [] hero_images: Optional[List[dict[str, Any]]] = []
media_gallery: Optional[List[dict[str, Any]]] = []
featured_media_id: Optional[str] = None
content_html: Optional[str] = None content_html: Optional[str] = None
source_url: Optional[str] = None source_url: Optional[str] = None
source_label: Optional[str] = None source_label: Optional[str] = None
@ -352,6 +396,15 @@ class PublicCommentCreateRequest(BaseModel):
class PublicMagicLinkRequest(BaseModel): class PublicMagicLinkRequest(BaseModel):
email: str email: str
return_to: Optional[str] = "/" return_to: Optional[str] = "/"
class PublicContactFormRequest(BaseModel):
name: str
email: str
topic: str
message: str
website: Optional[str] = ""
started_at: Optional[int] = None
# --- FUNKSJONER --- # --- FUNKSJONER ---
def format_row(row): def format_row(row):
""" """
@ -527,6 +580,43 @@ def format_article_row(row):
elif not isinstance(hero_images, list): elif not isinstance(hero_images, list):
data["hero_images"] = [] data["hero_images"] = []
media_gallery = data.get("media_gallery")
if media_gallery is None:
data["media_gallery"] = []
elif isinstance(media_gallery, str):
try:
data["media_gallery"] = json.loads(media_gallery)
except Exception:
data["media_gallery"] = []
elif not isinstance(media_gallery, list):
data["media_gallery"] = []
if not data["media_gallery"] and data["hero_images"]:
data["media_gallery"] = [
{
"id": build_article_media_id("image", image.get("src") or ""),
"type": "image",
"src": image.get("src") or "",
"alt": image.get("alt") or "",
"caption": image.get("caption") or "",
"poster": "",
}
for image in data["hero_images"]
if isinstance(image, dict) and str(image.get("src") or "").strip()
]
if not data.get("featured_media_id") and data["media_gallery"]:
first_image = next(
(
item
for item in data["media_gallery"]
if isinstance(item, dict) and str(item.get("type") or "image").strip().lower() == "image"
),
None,
)
if first_image:
data["featured_media_id"] = str(first_image.get("id") or "").strip() or None
return data return data
@ -575,7 +665,7 @@ def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
continue continue
sanitized.append( sanitized.append(
{ {
"src": src, "src": normalize_article_media_url(src),
"alt": str(item.get("alt") or "").strip(), "alt": str(item.get("alt") or "").strip(),
"caption": str(item.get("caption") or "").strip(), "caption": str(item.get("caption") or "").strip(),
} }
@ -583,6 +673,145 @@ def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
return sanitized return sanitized
LEGACY_ARTICLE_MEDIA_PATTERN = re.compile(
r"^https?://(?:www\.)?(?:teeoff\.no|nye\.teeoff\.no|wp\.teeoff\.no)(?P<path>/(?:wp-content/uploads|uploads/articles)/.+)$",
re.IGNORECASE,
)
YOUTUBE_THUMBNAIL_PATTERN = re.compile(
r"^https?://i\.ytimg\.com/vi(?:_webp)?/(?P<video_id>[^/]+)/",
re.IGNORECASE,
)
def normalize_article_media_url(value: str | None) -> str:
trimmed = str(value or "").strip()
if not trimmed:
return ""
legacy_match = LEGACY_ARTICLE_MEDIA_PATTERN.match(trimmed)
if legacy_match:
path = legacy_match.group("path")
if path.startswith("/wp-content/uploads/"):
return f"https://wp.teeoff.no{path}"
return path
return trimmed
def build_article_media_id(media_type: str, src: str) -> str:
digest = hashlib.sha1(f"{media_type}:{src}".encode("utf-8")).hexdigest()[:12]
return f"{media_type}-{digest}"
def sanitize_article_media(value: Any, title: str | None = None) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
sanitized: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
fallback_text = str(title or "").strip()
for item in value:
if not isinstance(item, dict):
continue
media_type = str(item.get("type") or "image").strip().lower()
if media_type not in {"image", "video"}:
continue
src = normalize_article_media_url(item.get("src"))
if not src:
continue
dedupe_key = (media_type, src)
if dedupe_key in seen:
continue
seen.add(dedupe_key)
poster = normalize_article_media_url(item.get("poster"))
alt = str(item.get("alt") or "").strip()
caption = str(item.get("caption") or "").strip()
media_id = str(item.get("id") or "").strip() or build_article_media_id(media_type, src)
sanitized.append(
{
"id": media_id,
"type": media_type,
"src": src,
"alt": alt or fallback_text,
"caption": caption or alt or fallback_text,
"poster": poster,
}
)
return sanitized
def build_media_gallery_from_hero_images(hero_images: list[dict[str, str]]) -> list[dict[str, str]]:
return [
{
"id": build_article_media_id("image", image["src"]),
"type": "image",
"src": image["src"],
"alt": image.get("alt") or "",
"caption": image.get("caption") or "",
"poster": "",
}
for image in hero_images
if image.get("src")
]
def sanitize_featured_media_id(featured_media_id: str | None, media_gallery: list[dict[str, str]]) -> str | None:
candidate = str(featured_media_id or "").strip()
if candidate and any(item.get("id") == candidate and item.get("type") == "image" for item in media_gallery):
return candidate
for item in media_gallery:
if item.get("type") == "image":
return item.get("id")
return None
def build_hero_images_from_media_gallery(
media_gallery: list[dict[str, str]],
fallback_hero_images: list[dict[str, str]],
featured_media_id: str | None,
) -> list[dict[str, str]]:
image_media = [
{
"src": item["src"],
"alt": item.get("alt") or "",
"caption": item.get("caption") or item.get("alt") or "",
"id": item.get("id") or "",
}
for item in media_gallery
if item.get("type") == "image" and item.get("src")
]
if not image_media:
return fallback_hero_images
if featured_media_id:
featured_index = next(
(index for index, item in enumerate(image_media) if item.get("id") == featured_media_id),
None,
)
if featured_index is not None and featured_index > 0:
featured_item = image_media.pop(featured_index)
image_media.insert(0, featured_item)
return [
{
"src": item["src"],
"alt": item.get("alt") or "",
"caption": item.get("caption") or item.get("alt") or "",
}
for item in image_media
]
def humanize_slug(slug: str | None) -> str: def humanize_slug(slug: str | None) -> str:
if not slug: if not slug:
return "Ukjent bane" return "Ukjent bane"
@ -898,6 +1127,8 @@ async def ensure_articles_table(conn):
author_name VARCHAR(255), author_name VARCHAR(255),
status VARCHAR(32) NOT NULL DEFAULT 'draft', status VARCHAR(32) NOT NULL DEFAULT 'draft',
hero_images JSONB NOT NULL DEFAULT '[]'::jsonb, hero_images JSONB NOT NULL DEFAULT '[]'::jsonb,
media_gallery JSONB NOT NULL DEFAULT '[]'::jsonb,
featured_media_id VARCHAR(255),
content_html TEXT, content_html TEXT,
source_url TEXT, source_url TEXT,
source_label VARCHAR(255), source_label VARCHAR(255),
@ -910,6 +1141,14 @@ async def ensure_articles_table(conn):
ALTER TABLE articles ALTER TABLE articles
ADD COLUMN IF NOT EXISTS section VARCHAR(32) NOT NULL DEFAULT 'banebesok' ADD COLUMN IF NOT EXISTS section VARCHAR(32) NOT NULL DEFAULT 'banebesok'
""") """)
await conn.execute("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS media_gallery JSONB NOT NULL DEFAULT '[]'::jsonb
""")
await conn.execute("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS featured_media_id VARCHAR(255)
""")
await conn.execute(""" await conn.execute("""
UPDATE articles UPDATE articles
SET section = 'banebesok' SET section = 'banebesok'
@ -1017,6 +1256,7 @@ async def lifespan(app: FastAPI):
await ensure_articles_table(conn) await ensure_articles_table(conn)
await ensure_public_user_tables(conn) await ensure_public_user_tables(conn)
await ensure_scrape_jobs_table(conn) await ensure_scrape_jobs_table(conn)
app.state.contact_submission_tracker = {}
print("✅ Database tilkoblet og pool opprettet") print("✅ Database tilkoblet og pool opprettet")
except Exception as e: except Exception as e:
print(f"❌ Databasefeil under oppstart: {e}") print(f"❌ Databasefeil under oppstart: {e}")
@ -1362,6 +1602,79 @@ async def request_magic_link(request: Request, payload: PublicMagicLinkRequest):
} }
@app.post("/api/public/contact")
async def submit_public_contact_form(request: Request, payload: PublicContactFormRequest):
if not is_contact_form_configured():
raise HTTPException(status_code=503, detail="Kontaktskjema er ikke konfigurert ennå.")
if str(payload.website or "").strip():
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
name = str(payload.name or "").strip()
email = normalize_public_email(payload.email)
topic = str(payload.topic or "").strip()
message = str(payload.message or "").strip()
if len(name) < 2 or len(name) > 120:
raise HTTPException(status_code=400, detail="Oppgi et gyldig navn.")
if not email or "@" not in email or len(email) > 255:
raise HTTPException(status_code=400, detail="Oppgi en gyldig e-postadresse.")
if len(topic) < 2 or len(topic) > 140:
raise HTTPException(status_code=400, detail="Oppgi et gyldig emne.")
if len(message) < 20 or len(message) > 5000:
raise HTTPException(status_code=400, detail="Meldingen må være mellom 20 og 5000 tegn.")
now_ts = int(datetime.utcnow().timestamp())
if payload.started_at and now_ts - int(payload.started_at) < CONTACT_FORM_MIN_FILL_SECONDS:
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
ip_hash = hash_request_ip(request)
tracker: dict[str, list[int]] = getattr(app.state, "contact_submission_tracker", {})
cutoff = now_ts - CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS
for key in list(tracker.keys()):
recent = [ts for ts in tracker.get(key, []) if ts >= cutoff]
if recent:
tracker[key] = recent
else:
tracker.pop(key, None)
rate_keys = [f"email:{email}"]
if ip_hash:
rate_keys.append(f"ip:{ip_hash}")
for key in rate_keys:
attempts = tracker.get(key, [])
if len(attempts) >= CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS:
raise HTTPException(
status_code=429,
detail="For mange meldinger på kort tid. Prøv igjen senere.",
)
await send_contact_form_email(
sender_name=name,
sender_email=email,
topic=topic,
message=message,
ip_hash=ip_hash,
)
for key in rate_keys:
tracker.setdefault(key, []).append(now_ts)
app.state.contact_submission_tracker = tracker
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
@app.get("/api/public/auth/magic-link/verify") @app.get("/api/public/auth/magic-link/verify")
async def verify_magic_link( async def verify_magic_link(
request: Request, request: Request,
@ -1720,18 +2033,23 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
if status == "published" and not published_at: if status == "published" and not published_at:
published_at = datetime.utcnow() published_at = datetime.utcnow()
hero_images = sanitize_hero_images(request.hero_images) fallback_hero_images = sanitize_hero_images(request.hero_images)
media_gallery = sanitize_article_media(request.media_gallery, request.title.strip())
if not media_gallery and fallback_hero_images:
media_gallery = build_media_gallery_from_hero_images(fallback_hero_images)
featured_media_id = sanitize_featured_media_id(request.featured_media_id, media_gallery)
hero_images = build_hero_images_from_media_gallery(media_gallery, fallback_hero_images, featured_media_id)
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(""" row = await conn.fetchrow("""
INSERT INTO articles ( INSERT INTO articles (
section, slug, title, description, excerpt, eyebrow, location_label, section, slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images, facility_name, facility_slug, author_name, status, hero_images,
content_html, source_url, source_label, published_at, updated_at media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12::jsonb, $8, $9, $10, $11, $12::jsonb,
$13, $14, $15, $16, $17 $13::jsonb, $14, $15, $16, $17, $18, $19
) )
ON CONFLICT (slug) DO UPDATE SET ON CONFLICT (slug) DO UPDATE SET
section = EXCLUDED.section, section = EXCLUDED.section,
@ -1745,6 +2063,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
author_name = EXCLUDED.author_name, author_name = EXCLUDED.author_name,
status = EXCLUDED.status, status = EXCLUDED.status,
hero_images = EXCLUDED.hero_images, hero_images = EXCLUDED.hero_images,
media_gallery = EXCLUDED.media_gallery,
featured_media_id = EXCLUDED.featured_media_id,
content_html = EXCLUDED.content_html, content_html = EXCLUDED.content_html,
source_url = EXCLUDED.source_url, source_url = EXCLUDED.source_url,
source_label = EXCLUDED.source_label, source_label = EXCLUDED.source_label,
@ -1764,6 +2084,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
(request.author_name or "TeeOff").strip(), (request.author_name or "TeeOff").strip(),
status, status,
json.dumps(hero_images), json.dumps(hero_images),
json.dumps(media_gallery),
featured_media_id,
request.content_html or "", request.content_html or "",
(request.source_url or "").strip() or None, (request.source_url or "").strip() or None,
(request.source_label or "").strip() or None, (request.source_label or "").strip() or None,
@ -1810,28 +2132,45 @@ async def seed_admin_articles_from_imported_json():
featured_image = item.get("featuredImage") or {} featured_image = item.get("featuredImage") or {}
section, eyebrow = resolve_imported_article_section(item) section, eyebrow = resolve_imported_article_section(item)
hero_images: list[dict[str, str]] = [] media_gallery: list[dict[str, str]] = []
featured_url = str(featured_image.get("url") or "").strip() featured_url = str(featured_image.get("url") or "").strip()
if featured_url: if featured_url:
hero_images.append( media_gallery.append(
{ {
"id": build_article_media_id("image", featured_url),
"type": "image",
"src": featured_url, "src": featured_url,
"alt": str(featured_image.get("alt") or item.get("title") or "").strip(), "alt": str(featured_image.get("alt") or item.get("title") or "").strip(),
"caption": str(featured_image.get("caption") or item.get("title") or "").strip(), "caption": str(featured_image.get("caption") or item.get("title") or "").strip(),
"poster": "",
} }
) )
for url in extract_html_image_urls(content_html)[:5]: for url in extract_html_image_urls(content_html)[:5]:
if any(existing["src"] == url for existing in hero_images): if any(existing["src"] == url for existing in media_gallery):
continue continue
hero_images.append( media_gallery.append(
{ {
"id": build_article_media_id("image", url),
"type": "image",
"src": url, "src": url,
"alt": str(item.get("title") or "").strip(), "alt": str(item.get("title") or "").strip(),
"caption": str(item.get("title") or "").strip(), "caption": str(item.get("title") or "").strip(),
"poster": "",
} }
) )
sanitized_media_gallery = sanitize_article_media(media_gallery, str(item.get("title") or "").strip())
featured_media_id = sanitize_featured_media_id(
sanitized_media_gallery[0]["id"] if sanitized_media_gallery else None,
sanitized_media_gallery,
)
hero_images = build_hero_images_from_media_gallery(
sanitized_media_gallery,
[],
featured_media_id,
)
published_at = parse_optional_datetime(item.get("publishedAt")) published_at = parse_optional_datetime(item.get("publishedAt"))
updated_at = parse_optional_datetime(item.get("updatedAt")) or published_at or datetime.utcnow() updated_at = parse_optional_datetime(item.get("updatedAt")) or published_at or datetime.utcnow()
@ -1839,11 +2178,11 @@ async def seed_admin_articles_from_imported_json():
INSERT INTO articles ( INSERT INTO articles (
section, slug, title, description, excerpt, eyebrow, location_label, section, slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images, facility_name, facility_slug, author_name, status, hero_images,
content_html, source_url, source_label, published_at, updated_at media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, 'published', $11::jsonb, $8, $9, $10, 'published', $11::jsonb,
$12, $13, $14, $15, $16 $12::jsonb, $13, $14, $15, $16, $17, $18
) )
ON CONFLICT (slug) DO UPDATE SET ON CONFLICT (slug) DO UPDATE SET
section = EXCLUDED.section, section = EXCLUDED.section,
@ -1857,6 +2196,8 @@ async def seed_admin_articles_from_imported_json():
author_name = EXCLUDED.author_name, author_name = EXCLUDED.author_name,
status = EXCLUDED.status, status = EXCLUDED.status,
hero_images = EXCLUDED.hero_images, hero_images = EXCLUDED.hero_images,
media_gallery = EXCLUDED.media_gallery,
featured_media_id = EXCLUDED.featured_media_id,
content_html = EXCLUDED.content_html, content_html = EXCLUDED.content_html,
source_url = EXCLUDED.source_url, source_url = EXCLUDED.source_url,
source_label = EXCLUDED.source_label, source_label = EXCLUDED.source_label,
@ -1874,6 +2215,8 @@ async def seed_admin_articles_from_imported_json():
str(facility_slug) if facility_slug else None, str(facility_slug) if facility_slug else None,
str(((item.get("author") or {}).get("name")) or "TeeOff"), str(((item.get("author") or {}).get("name")) or "TeeOff"),
json.dumps(hero_images), json.dumps(hero_images),
json.dumps(sanitized_media_gallery),
featured_media_id,
content_html, content_html,
str(item.get("link") or "").strip() or None, str(item.get("link") or "").strip() or None,
"Importert fra gamle TeeOff", "Importert fra gamle TeeOff",

View file

@ -1,7 +1,7 @@
""" """
TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider. Henter pris, beskrivelse (inkl. lånekøller/medlemskap/pakker) og kursdatoer fra VTG-sider.
Støtter kommaseparerte URL-er. Støtter kommaseparerte URL-er.
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
""" """
@ -58,9 +58,15 @@ Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fr
OPPGAVER: OPPGAVER:
1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet). 1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet).
VIKTIG PRISLOGIKK:
- Hvis klubben tilbyr både "kun VTG-kurs" og en pakke som inkluderer medlemskap/spillerett, skal du velge prisen KUN SELVE KURSET som foreslatt_vtg_pris.
- Hvis klubben tilbyr et rimeligere kurs uten medlemskap, men også en dyrere pakke med medlemskap, er det alltid kursprisen uten medlemskap som skal returneres.
- Hvis klubben BARE tilbyr en samlet pakke med VTG + medlemskap/spillerett, returnerer du pakkeprisen.
- Ignorer medlemskapstilbud som ikke faktisk er knyttet til VTG-kurset.
- Ignorer priser for barn, junior, student, familie eller andre spesialgrupper hvis det finnes en vanlig voksenpris.
2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du inkludere informasjon om: 2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du inkludere informasjon om:
- Er lån av køller/utstyr inkludert i kurset?
- Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)? - Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)?
- Hvis klubben tilbyr både kurs uten medlemskap og en egen pakke med medlemskap, du nevne dette eksplisitt og oppgi pakkeprisen hvis den finnes i teksten.
- Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring"). - Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring").
Ignorer uvesentlig støy og lange historiske utgreiinger. Ignorer uvesentlig støy og lange historiske utgreiinger.
3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste"). 3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste").
@ -72,14 +78,17 @@ OPPGAVE:
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur: Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
{{ {{
"foreslatt_vtg_pris": 1990, "foreslatt_vtg_pris": 1990,
"foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.", "foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring, og lån av golfkøller er inkludert. Selve VTG-kurset koster 1990 kroner. Klubben tilbyr også en pakke med kurs og medlemskap ut året til 3490 kroner.",
"foreslatt_vtg_datoer": [ "foreslatt_vtg_datoer": [
{{"dato": "12.-14. mai", "status": "Fulltegnet"}}, {{"dato": "12.-14. mai", "status": "Fulltegnet"}},
{{"dato": "5.-7. juni", "status": "Ledig"}} {{"dato": "5.-7. juni", "status": "Ledig"}}
], ],
"ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr." "ai_begrunnelse": "Fant voksenpris på 1990 kroner for selve VTG-kurset. Det stod også at klubben har en egen pakke med medlemskap til 3490 kroner, samt at lån av utstyr er inkludert."
}} }}
Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom []. Merk:
- Sett foreslatt_vtg_pris til null (null) hvis du ikke finner en tydelig voksenpris.
- Hvis du ikke finner datoer, la listen være tom [].
- Hvis prisen du returnerer faktisk er en pakkepris med medlemskap, det sies tydelig i foreslatt_vtg_beskrivelse og ai_begrunnelse.
""" """
try: try:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Some files were not shown because too many files have changed in this diff Show more