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_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",
|
||||||
|
|
|
||||||
|
|
@ -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 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:
|
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)?
|
- 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").
|
- 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, må det sies tydelig i foreslatt_vtg_beskrivelse og ai_begrunnelse.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
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 |