teecup/app/routers/tournaments.py
Erol Haagenrud 1e491d73b3 MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.

To reelle feil funnet under scratch-verifisering, aldri i produksjon:

pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.

Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.

Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.

Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00

703 lines
24 KiB
Python

"""
Turneringsoppsett: turneringer, lag, roster, økter.
Autorisasjonsgrense (se plan): kun org-medlemskap (get_authorized_org) kreves
her — dette er organisator-arbeid. Å kreve at brukeren allerede er rostret på
et lag ville vært sirkulært for lag-/roster-opprettelse (ingen kan være
rostret på et lag som ikke finnes ennå).
"""
import json
from datetime import date, datetime
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel, Field
from ..auth import get_authorized_org
from ..blind_draw import locked_team_ids
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from .. import storage
router = APIRouter()
# ---------------------------------------------------------------------------
# Turneringer
# ---------------------------------------------------------------------------
class TournamentCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
start_date: date | None = None
end_date: date | None = None
_TOURNAMENT_COLUMNS = """
id::text, name, status::text, start_date, end_date,
visibility, description, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval, hero_image_key
"""
class Tournament(BaseModel):
id: str
name: str
status: str
start_date: date | None
end_date: date | None
visibility: str
description: str | None
registration_deadline: datetime | None
registration_capacity: int | None
registration_overflow_policy: str
registration_requires_approval: bool
hero_image_key: str | None
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
async def list_tournaments(
organization_id: str = Depends(get_authorized_org),
) -> list[Tournament]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
f"SELECT {_TOURNAMENT_COLUMNS} FROM tournament ORDER BY created_at DESC"
)
return [Tournament(**dict(r)) for r in rows]
@router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
async def create_tournament(
body: TournamentCreate,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
f"""
INSERT INTO tournament (organization_id, name, start_date, end_date)
VALUES ($1, $2, $3, $4)
RETURNING {_TOURNAMENT_COLUMNS}
""",
organization_id,
body.name,
body.start_date,
body.end_date,
)
return Tournament(**dict(row))
class TournamentUpdate(BaseModel):
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
(PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament)."""
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
description: str | None = None
registration_deadline: datetime | None = None
registration_capacity: int | None = Field(default=None, gt=0)
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
registration_requires_approval: bool | None = None
# hero_image_key er BEVISST IKKE med her -- den settes kun via
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
@router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament)
async def update_tournament(
tournament_id: str,
body: TournamentUpdate,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
# exclude_unset: kun felt klienten FAKTISK sendte skal skrives -- et
# utelatt felt skal IKKE nullstilles (ekte PATCH-semantikk, ikke PUT).
updates = body.model_dump(exclude_unset=True)
if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
# Trygt mot injeksjon: nøklene kommer KUN fra TournamentUpdate sine egne
# feltnavn (en fast, kjent hviteliste), aldri fra en rå streng brukeren
# sender inn.
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
values = list(updates.values())
values.append(tournament_id)
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
f"""
UPDATE tournament SET {', '.join(set_clauses)}
WHERE id = ${len(values)}
RETURNING {_TOURNAMENT_COLUMNS}
""",
*values,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
return Tournament(**dict(row))
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
response_model=Tournament,
)
async def upload_hero_image(
tournament_id: str,
file: UploadFile,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
"""Ekte multipart-opplasting (IKKE en presignert nettleser-URL): filen
går gjennom API-et, som konverterer den til AVIF før den lagres i MinIO
-- se app/storage.py sin moduldocstring for hvorfor."""
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
if len(raw) > storage.MAX_UPLOAD_BYTES:
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
async with org_connection(organization_id) as conn:
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
if exists is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
try:
key = await storage.upload_image("tournaments", tournament_id, raw)
except storage.InvalidImageError:
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
row = await conn.fetchrow(
f"UPDATE tournament SET hero_image_key = $1 WHERE id = $2 RETURNING {_TOURNAMENT_COLUMNS}",
key,
tournament_id,
)
return Tournament(**dict(row))
# ---------------------------------------------------------------------------
# Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet —
# slik at match-modellen kan holdes generell for fremtidige turneringstyper)
# ---------------------------------------------------------------------------
class TeamCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
color: str | None = None
class Team(BaseModel):
id: str
name: str
color: str | None
@router.get("/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=list[Team])
async def list_teams(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[Team]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
tournament_id,
)
return [Team(**dict(r)) for r in rows]
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/teams",
response_model=Team,
status_code=201,
)
async def create_team(
tournament_id: str,
body: TeamCreate,
organization_id: str = Depends(get_authorized_org),
) -> Team:
async with org_connection(organization_id) as conn, translate_db_errors():
# Lås turneringsraden slik at to samtidige forespørsler ikke begge kan
# lese "1 lag" og begge sette inn et andre/tredje lag (TOCTOU).
locked = await conn.fetchrow(
"SELECT id FROM tournament WHERE id = $1 AND organization_id = $2 FOR UPDATE",
tournament_id,
organization_id,
)
if locked is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
existing = await conn.fetchval(
"SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id
)
if existing >= 2:
raise app_error(
409, "LIMIT_REACHED", "Turneringen har allerede to lag (ADR-011: nøyaktig to lag)."
)
row = await conn.fetchrow(
"""
INSERT INTO team (organization_id, tournament_id, name, color)
VALUES ($1, $2, $3, $4)
RETURNING id::text, name, color
""",
organization_id,
tournament_id,
body.name,
body.color,
)
return Team(**dict(row))
# ---------------------------------------------------------------------------
# Roster (spiller-pool per lag, ADR-007)
# ---------------------------------------------------------------------------
class RosterEntryCreate(BaseModel):
player_id: str
is_captain: bool = False
# None => fryses fra player.handicap_index ved innsetting (ADR-007).
handicap_index_snapshot: float | None = None
class RosterEntry(BaseModel):
id: str
player_id: str
display_name: str
handicap_index_snapshot: float | None
is_captain: bool
@router.get("/orgs/{organization_id}/teams/{team_id}/roster", response_model=list[RosterEntry])
async def list_roster(
team_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[RosterEntry]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT tr.id::text, tr.player_id::text,
tr.handicap_index_snapshot::float AS handicap_index_snapshot,
tr.is_captain, p.display_name
FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1
ORDER BY p.display_name
""",
team_id,
)
return [RosterEntry(**dict(r)) for r in rows]
@router.post(
"/orgs/{organization_id}/teams/{team_id}/roster",
response_model=RosterEntry,
status_code=201,
)
async def add_roster_entry(
team_id: str,
body: RosterEntryCreate,
organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
async with org_connection(organization_id) as conn, translate_db_errors():
team = await conn.fetchrow("SELECT tournament_id FROM team WHERE id = $1", team_id)
if team is None:
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
# Skjemaet håndhever kun unikhet PER lag (UNIQUE(team_id, player_id)).
# Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme
# turnering, og own_team_ids() (blind draw-filteret i matches.py) ville
# da regnet hen som "eget lag" på begge sider — hen ville sett
# motstanderens skjulte oppstilling før lås (ADR-013).
already_elsewhere = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN team t ON t.id = tr.team_id
WHERE t.tournament_id = $1 AND tr.player_id = $2 AND tr.team_id <> $3
)
""",
team["tournament_id"],
body.player_id,
team_id,
)
if already_elsewhere:
raise app_error(
409,
"DUPLICATE",
"Spilleren er allerede rostret på det andre laget i denne turneringen.",
)
snapshot = body.handicap_index_snapshot
if snapshot is None:
snapshot = await conn.fetchval(
"SELECT handicap_index FROM player WHERE id = $1", body.player_id
)
row = await conn.fetchrow(
"""
WITH inserted AS (
INSERT INTO team_roster
(organization_id, team_id, player_id, handicap_index_snapshot, is_captain)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, player_id, handicap_index_snapshot, is_captain
)
SELECT inserted.id::text, inserted.player_id::text,
inserted.handicap_index_snapshot::float AS handicap_index_snapshot,
inserted.is_captain, p.display_name
FROM inserted JOIN player p ON p.id = inserted.player_id
""",
organization_id,
team_id,
body.player_id,
snapshot,
body.is_captain,
)
return RosterEntry(**dict(row))
class RosterEntryUpdate(BaseModel):
is_captain: bool
@router.patch(
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
response_model=RosterEntry,
)
async def update_roster_entry(
team_id: str,
roster_id: str,
body: RosterEntryUpdate,
organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
# Bevisst enkelt: setter/fjerner kapteinmerket på NØYAKTIG denne raden,
# håndhever ikke "kun én kaptein per lag" -- kaptein er i dag bare et
# merke, ikke en egen autorisasjonsrolle (se FEATURE_BACKLOG).
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"""
WITH updated AS (
UPDATE team_roster
SET is_captain = $1
WHERE id = $2 AND team_id = $3
RETURNING id, player_id, handicap_index_snapshot, is_captain
)
SELECT updated.id::text, updated.player_id::text,
updated.handicap_index_snapshot::float AS handicap_index_snapshot,
updated.is_captain, p.display_name
FROM updated JOIN player p ON p.id = updated.player_id
""",
body.is_captain,
roster_id,
team_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
return RosterEntry(**dict(row))
@router.delete(
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
status_code=204,
)
async def delete_roster_entry(
team_id: str,
roster_id: str,
organization_id: str = Depends(get_authorized_org),
) -> None:
async with org_connection(organization_id) as conn:
deleted = await conn.fetchval(
"DELETE FROM team_roster WHERE id = $1 AND team_id = $2 RETURNING id",
roster_id,
team_id,
)
if deleted is None:
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
# ---------------------------------------------------------------------------
# Økter (ADR-007: turnering = ordnet sekvens av økter)
# ---------------------------------------------------------------------------
class SessionCreate(BaseModel):
sequence: int
name: str | None = None
format: str
hole_config: str = "full_18"
course_id: str
points_per_match: float = 1.0
allowance_override: dict | None = None
# ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result'
# (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'.
scoring_mode: str = "stroke"
# Klokkeslett-modell (se plan): én starttid + ett intervall per økt.
# match.tee_time utledes av disse i matches.py, IKKE lagret per match.
scheduled_at: datetime | None = None
tee_interval_minutes: int | None = None
start_hole: int = 1
class SessionOut(BaseModel):
id: str
sequence: int
name: str | None
format: str
hole_config: str
course_id: str
points_per_match: float
allowance_override: dict | None
scoring_mode: str
scheduled_at: datetime | None
tee_interval_minutes: int | None
start_hole: int
locked_team_ids: list[str]
revealed: bool
def _session_out(row, locked: set[str]) -> SessionOut:
return SessionOut(
id=row["id"],
sequence=row["sequence"],
name=row["name"],
format=row["format"],
hole_config=row["hole_config"],
course_id=row["course_id"],
points_per_match=row["points_per_match"],
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
scoring_mode=row["scoring_mode"],
scheduled_at=row["scheduled_at"],
tee_interval_minutes=row["tee_interval_minutes"],
start_hole=row["start_hole"],
locked_team_ids=sorted(locked),
revealed=len(locked) >= 2,
)
async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
"""Delt mellom den innloggede org-ruten under og det offentlige
landingsside-endepunktet (app/routers/registration.py, ADR-018) -- samme
spørring, samme blind draw-låsing, ikke duplisert to steder."""
rows = await conn.fetch(
"""
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole
FROM session
WHERE tournament_id = $1
ORDER BY sequence
""",
tournament_id,
)
result = []
for r in rows:
locked = await locked_team_ids(conn, r["id"])
result.append(_session_out(r, locked))
return result
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
response_model=list[SessionOut],
)
async def list_sessions(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[SessionOut]:
async with org_connection(organization_id) as conn:
return await _fetch_sessions(conn, tournament_id)
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
response_model=SessionOut,
status_code=201,
)
async def create_session(
tournament_id: str,
body: SessionCreate,
organization_id: str = Depends(get_authorized_org),
) -> SessionOut:
allowance_json = json.dumps(body.allowance_override) if body.allowance_override is not None else None
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
"""
INSERT INTO session
(organization_id, tournament_id, sequence, name, format,
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13)
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole
""",
organization_id,
tournament_id,
body.sequence,
body.name,
body.format,
body.hole_config,
body.course_id,
body.points_per_match,
allowance_json,
body.scoring_mode,
body.scheduled_at,
body.tee_interval_minutes,
body.start_hole,
)
return _session_out(row, set())
# ---------------------------------------------------------------------------
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
# ---------------------------------------------------------------------------
class SponsorCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: str | None = None
_SPONSOR_COLUMNS = "id::text, name, url, logo_key"
class Sponsor(BaseModel):
id: str
name: str
url: str | None
logo_key: str | None
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors",
response_model=list[Sponsor],
)
async def list_sponsors(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[Sponsor]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
tournament_id,
)
return [Sponsor(**dict(r)) for r in rows]
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors",
response_model=Sponsor,
status_code=201,
)
async def add_sponsor(
tournament_id: str,
body: SponsorCreate,
organization_id: str = Depends(get_authorized_org),
) -> Sponsor:
async with org_connection(organization_id) as conn, translate_db_errors():
tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
if tournament is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
row = await conn.fetchrow(
f"""
INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url)
VALUES ($1, $2, $3, $4)
RETURNING {_SPONSOR_COLUMNS}
""",
organization_id,
tournament_id,
body.name,
body.url,
)
return Sponsor(**dict(row))
class SponsorUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
url: str | None = None
# logo_key er BEVISST IKKE med -- settes kun via upload_sponsor_logo
# under, samme begrunnelse som tournament.hero_image_key over.
@router.patch(
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
response_model=Sponsor,
)
async def update_sponsor(
tournament_id: str,
sponsor_id: str,
body: SponsorUpdate,
organization_id: str = Depends(get_authorized_org),
) -> Sponsor:
updates = body.model_dump(exclude_unset=True)
if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
values = list(updates.values())
values.append(sponsor_id)
values.append(tournament_id)
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
f"""
UPDATE tournament_sponsor SET {', '.join(set_clauses)}
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
RETURNING {_SPONSOR_COLUMNS}
""",
*values,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
return Sponsor(**dict(row))
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}/logo",
response_model=Sponsor,
)
async def upload_sponsor_logo(
tournament_id: str,
sponsor_id: str,
file: UploadFile,
organization_id: str = Depends(get_authorized_org),
) -> Sponsor:
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
if len(raw) > storage.MAX_UPLOAD_BYTES:
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
async with org_connection(organization_id) as conn:
exists = await conn.fetchval(
"SELECT id FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2",
sponsor_id,
tournament_id,
)
if exists is None:
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
try:
key = await storage.upload_image("sponsors", sponsor_id, raw)
except storage.InvalidImageError:
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
row = await conn.fetchrow(
f"UPDATE tournament_sponsor SET logo_key = $1 WHERE id = $2 RETURNING {_SPONSOR_COLUMNS}",
key,
sponsor_id,
)
return Sponsor(**dict(row))
@router.delete(
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
status_code=204,
)
async def delete_sponsor(
tournament_id: str,
sponsor_id: str,
organization_id: str = Depends(get_authorized_org),
) -> None:
async with org_connection(organization_id) as conn:
deleted = await conn.fetchval(
"DELETE FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2 RETURNING id",
sponsor_id,
tournament_id,
)
if deleted is None:
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")