Etter mange bugfix etter første dag i drift
363
backend/main.py
|
|
@ -68,6 +68,7 @@ SMTP_PORT = os.getenv("SMTP_PORT", "").strip()
|
|||
SMTP_USER = os.getenv("SMTP_USER", "").strip()
|
||||
SMTP_PASS = os.getenv("SMTP_PASS", "").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")
|
||||
|
||||
|
|
@ -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)
|
||||
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)
|
||||
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:
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
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]:
|
||||
google_enabled = is_google_login_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)
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
|
|
@ -338,6 +380,8 @@ class ArticleUpsertRequest(BaseModel):
|
|||
author_name: Optional[str] = None
|
||||
status: Optional[str] = "draft"
|
||||
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
|
||||
source_url: Optional[str] = None
|
||||
source_label: Optional[str] = None
|
||||
|
|
@ -352,6 +396,15 @@ class PublicCommentCreateRequest(BaseModel):
|
|||
class PublicMagicLinkRequest(BaseModel):
|
||||
email: 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 ---
|
||||
def format_row(row):
|
||||
"""
|
||||
|
|
@ -527,6 +580,43 @@ def format_article_row(row):
|
|||
elif not isinstance(hero_images, list):
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -575,7 +665,7 @@ def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
|
|||
continue
|
||||
sanitized.append(
|
||||
{
|
||||
"src": src,
|
||||
"src": normalize_article_media_url(src),
|
||||
"alt": str(item.get("alt") 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
|
||||
|
||||
|
||||
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:
|
||||
if not slug:
|
||||
return "Ukjent bane"
|
||||
|
|
@ -898,6 +1127,8 @@ async def ensure_articles_table(conn):
|
|||
author_name VARCHAR(255),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
hero_images JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
media_gallery JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
featured_media_id VARCHAR(255),
|
||||
content_html TEXT,
|
||||
source_url TEXT,
|
||||
source_label VARCHAR(255),
|
||||
|
|
@ -910,6 +1141,14 @@ async def ensure_articles_table(conn):
|
|||
ALTER TABLE articles
|
||||
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("""
|
||||
UPDATE articles
|
||||
SET section = 'banebesok'
|
||||
|
|
@ -1017,6 +1256,7 @@ async def lifespan(app: FastAPI):
|
|||
await ensure_articles_table(conn)
|
||||
await ensure_public_user_tables(conn)
|
||||
await ensure_scrape_jobs_table(conn)
|
||||
app.state.contact_submission_tracker = {}
|
||||
print("✅ Database tilkoblet og pool opprettet")
|
||||
except Exception as 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")
|
||||
async def verify_magic_link(
|
||||
request: Request,
|
||||
|
|
@ -1720,18 +2033,23 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
|
|||
if status == "published" and not published_at:
|
||||
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:
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO articles (
|
||||
section, slug, title, description, excerpt, eyebrow, location_label,
|
||||
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 (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$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
|
||||
section = EXCLUDED.section,
|
||||
|
|
@ -1745,6 +2063,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
|
|||
author_name = EXCLUDED.author_name,
|
||||
status = EXCLUDED.status,
|
||||
hero_images = EXCLUDED.hero_images,
|
||||
media_gallery = EXCLUDED.media_gallery,
|
||||
featured_media_id = EXCLUDED.featured_media_id,
|
||||
content_html = EXCLUDED.content_html,
|
||||
source_url = EXCLUDED.source_url,
|
||||
source_label = EXCLUDED.source_label,
|
||||
|
|
@ -1764,6 +2084,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
|
|||
(request.author_name or "TeeOff").strip(),
|
||||
status,
|
||||
json.dumps(hero_images),
|
||||
json.dumps(media_gallery),
|
||||
featured_media_id,
|
||||
request.content_html or "",
|
||||
(request.source_url 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 {}
|
||||
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()
|
||||
if featured_url:
|
||||
hero_images.append(
|
||||
media_gallery.append(
|
||||
{
|
||||
"id": build_article_media_id("image", featured_url),
|
||||
"type": "image",
|
||||
"src": featured_url,
|
||||
"alt": str(featured_image.get("alt") 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]:
|
||||
if any(existing["src"] == url for existing in hero_images):
|
||||
if any(existing["src"] == url for existing in media_gallery):
|
||||
continue
|
||||
hero_images.append(
|
||||
media_gallery.append(
|
||||
{
|
||||
"id": build_article_media_id("image", url),
|
||||
"type": "image",
|
||||
"src": url,
|
||||
"alt": 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"))
|
||||
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 (
|
||||
section, slug, title, description, excerpt, eyebrow, location_label,
|
||||
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 (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$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
|
||||
section = EXCLUDED.section,
|
||||
|
|
@ -1857,6 +2196,8 @@ async def seed_admin_articles_from_imported_json():
|
|||
author_name = EXCLUDED.author_name,
|
||||
status = EXCLUDED.status,
|
||||
hero_images = EXCLUDED.hero_images,
|
||||
media_gallery = EXCLUDED.media_gallery,
|
||||
featured_media_id = EXCLUDED.featured_media_id,
|
||||
content_html = EXCLUDED.content_html,
|
||||
source_url = EXCLUDED.source_url,
|
||||
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(((item.get("author") or {}).get("name")) or "TeeOff"),
|
||||
json.dumps(hero_images),
|
||||
json.dumps(sanitized_media_gallery),
|
||||
featured_media_id,
|
||||
content_html,
|
||||
str(item.get("link") or "").strip() or None,
|
||||
"Importert fra gamle TeeOff",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
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.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
|
|
@ -58,9 +58,15 @@ Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fr
|
|||
|
||||
OPPGAVER:
|
||||
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 på 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 MÅ 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)?
|
||||
- Hvis klubben tilbyr både kurs uten medlemskap og en egen pakke med medlemskap, må du nevne dette eksplisitt og oppgi pakkeprisen hvis den finnes i teksten.
|
||||
- Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring").
|
||||
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").
|
||||
|
|
@ -72,14 +78,17 @@ OPPGAVE:
|
|||
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
|
||||
{{
|
||||
"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": [
|
||||
{{"dato": "12.-14. mai", "status": "Fulltegnet"}},
|
||||
{{"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, må det sies tydelig i foreslatt_vtg_beskrivelse og ai_begrunnelse.
|
||||
"""
|
||||
|
||||
try:
|
||||
|
|
|
|||
BIN
bilde1.png
|
Before Width: | Height: | Size: 17 KiB |
BIN
frontend/public/wp-content/uploads/13-1.jpg
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
frontend/public/wp-content/uploads/13-utslag-1.jpg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
frontend/public/wp-content/uploads/13green-1.jpg
Normal file
|
After Width: | Height: | Size: 346 KiB |
BIN
frontend/public/wp-content/uploads/15-1.jpg
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
frontend/public/wp-content/uploads/15-3-1.jpg
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
frontend/public/wp-content/uploads/15green-1.jpg
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
frontend/public/wp-content/uploads/16-1-1.jpg
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
frontend/public/wp-content/uploads/16-2-1.jpg
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
frontend/public/wp-content/uploads/16-3-1.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
frontend/public/wp-content/uploads/17-1.jpg
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
frontend/public/wp-content/uploads/18-1-1.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
frontend/public/wp-content/uploads/18-green-1-1.jpg
Normal file
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 306 KiB |
BIN
frontend/public/wp-content/uploads/Aapne-baner169.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
frontend/public/wp-content/uploads/AurskogGK1.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
frontend/public/wp-content/uploads/Baneaapning.jpg
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
frontend/public/wp-content/uploads/BjaavannBloggen.jpg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
frontend/public/wp-content/uploads/Bjaavannbloggen2.jpg
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
frontend/public/wp-content/uploads/BodoGP_video.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
frontend/public/wp-content/uploads/Budersand-Sylt-DE.jpg
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
frontend/public/wp-content/uploads/DSC_0706.jpg
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
frontend/public/wp-content/uploads/DrammenGK-sommertilbud.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/public/wp-content/uploads/DrammenGK.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
frontend/public/wp-content/uploads/DrammenGK1691.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
frontend/public/wp-content/uploads/DrammenGK1692.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
frontend/public/wp-content/uploads/DrammenGK1693.jpg
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
frontend/public/wp-content/uploads/DrammenGK1694.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/public/wp-content/uploads/DroebakGK.jpg
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
frontend/public/wp-content/uploads/EGA.jpg
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
frontend/public/wp-content/uploads/EgersundTopp.jpg
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
frontend/public/wp-content/uploads/Falsterbo-Skaane-SE.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
frontend/public/wp-content/uploads/FetGK-1.jpg
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
frontend/public/wp-content/uploads/Funksjon_golfpakker1.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/public/wp-content/uploads/GjersjoenGK2-169.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/public/wp-content/uploads/GjersjoenGK2.jpg
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
frontend/public/wp-content/uploads/Golfbanefotografering.jpg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
frontend/public/wp-content/uploads/Golfbanen_Norge1.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
frontend/public/wp-content/uploads/GriniGK-1.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
frontend/public/wp-content/uploads/HakadalBloggen.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
frontend/public/wp-content/uploads/HaugerGK1.jpg
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
frontend/public/wp-content/uploads/HemsedalGK.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
frontend/public/wp-content/uploads/Hjemmeside-velkomstbilde.jpg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
frontend/public/wp-content/uploads/HoltsmarkGK.jpg
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
frontend/public/wp-content/uploads/Hull-01.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
frontend/public/wp-content/uploads/Hull-02.jpg
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
frontend/public/wp-content/uploads/Hull-03-green.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
frontend/public/wp-content/uploads/Hull-03.jpg
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
frontend/public/wp-content/uploads/Hull-04-green.jpg
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
frontend/public/wp-content/uploads/Hull-05-green.jpg
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
frontend/public/wp-content/uploads/Hull-07.jpg
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
frontend/public/wp-content/uploads/Hull-08.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
frontend/public/wp-content/uploads/Hull-09-green.jpg
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
frontend/public/wp-content/uploads/Hull-09.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
frontend/public/wp-content/uploads/Hull-1-Herdla.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/wp-content/uploads/Hull-1-Ogna.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/wp-content/uploads/Hull-10-Drammen.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/wp-content/uploads/Hull-10-Nes-09.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/wp-content/uploads/Hull-11-Sandefjord.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/wp-content/uploads/Hull-11-paavei.jpg
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
frontend/public/wp-content/uploads/Hull-12-Fana.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/wp-content/uploads/Hull-12.jpg
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
frontend/public/wp-content/uploads/Hull-13-Sunnfjord.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/wp-content/uploads/Hull-14-Atlungstad.jpg
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/wp-content/uploads/Hull-15-Onsoey.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/wp-content/uploads/Hull-15.jpg
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
frontend/public/wp-content/uploads/Hull-16-Trysil.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/wp-content/uploads/Hull-16.jpg
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
frontend/public/wp-content/uploads/Hull-17-Haga.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/wp-content/uploads/Hull-18-Bjaavann.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/wp-content/uploads/Hull-18-Elverum.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/wp-content/uploads/Hull-18-green.jpg
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
frontend/public/wp-content/uploads/Hull-2-Steinkjer.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/wp-content/uploads/Hull-3-Asker1.jpg
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/wp-content/uploads/Hull-4-Midt-Troms.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/wp-content/uploads/Hull-4-Molde.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/wp-content/uploads/Hull-5-Hauger.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/wp-content/uploads/Hull-5-Kvinnherad.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/wp-content/uploads/Hull-6-Jaeren.jpg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
frontend/public/wp-content/uploads/Hull-7-Egersund.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/wp-content/uploads/Hull-7-Gjerdrum.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/wp-content/uploads/Hull-8-Lofoten-Links.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/wp-content/uploads/Hull-8-Trondheim.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/wp-content/uploads/Hull-9-Kongsberg.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/wp-content/uploads/Hull-9-Noetteroey.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/public/wp-content/uploads/Hull18-BjaavannGK1.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
frontend/public/wp-content/uploads/Hull3-OsloGK1.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
frontend/public/wp-content/uploads/Hull6.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
frontend/public/wp-content/uploads/IMG_1036-450x300.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/wp-content/uploads/IMG_1036-970x647.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
frontend/public/wp-content/uploads/IMG_1036.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
frontend/public/wp-content/uploads/IMG_1390_1600.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
frontend/public/wp-content/uploads/IMG_20161010_123752.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
frontend/public/wp-content/uploads/IMG_20161010_124430.jpg
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
frontend/public/wp-content/uploads/IMG_20161010_130801.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
frontend/public/wp-content/uploads/IMG_20161010_130840.jpg
Normal file
|
After Width: | Height: | Size: 159 KiB |