teecup/app/email.py
Erol Haagenrud 7d28727cb7 Kontosammenslåing, selvbetjent (ADR-080, "Del 2" av flere e-postadresser)
Selvbetjent sammenslåing av to TeeCup-kontoer: keeper (initiativtaker)
ber om sammenslåing, bekreftelseslenke sendt til taperens e-post beviser
eierskap, taperens data (org-medlemskap/spillerkoblinger/runder/venner)
flyttes over og taperens konto slettes. Ny migrasjon 077 (account_merge_
token, ikke kjørt mot ekte teecup_db ennå), ny app/account_merge.py
(N+1-transaksjoner per RLS-grensen, fullt konfliktkart), nye endepunkter
i app/routers/account_merge.py, ny frontend-seksjon i kontoinnstillinger
+ egen bekreftelsesside.

Fant og fikset en reell RLS-relatert bug via testsuiten før produksjon:
seks RLS-beskyttede tabeller var feilaktig plassert i den globale
"trygt å re-peke"-løkken, forårsaket et krasj pga. en Postgres GUC-kvirk
på pooled forbindelser. Se ADR-080 for full begrunnelse.

12 nye tester (95/95 backend totalt), full scratch-verifisert
ende-til-ende inkl. lys/mørk, ekte teecup_db urørt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:56:41 +02:00

832 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
E-postutsending for TeeCup. I dag kun magic-link-innlogging.
Egne, uavhengige SMTP-credentials fra teeoff (ADR-009) -- se .env
(TEECUP_SMTP_SERVER/PORT/USER/PASS, TEECUP_FROM_EMAIL). `smtplib` er
synkron, så selve sendingen kjøres i en tråd (asyncio.to_thread) for ikke å
blokkere event loop-en -- samme mønster som teeoffs egen, fungerende
magic-link-utsending.
Håndterer BEGGE vanlige SMTP-tilkoblingsmåter siden porten ikke er antatt på
forhånd: implisitt TLS (SMTP_SSL, typisk port 465) eller STARTTLS på en
vanlig tilkobling (typisk port 587/25).
"""
import smtplib
from asyncio import to_thread
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date, datetime
from email.message import EmailMessage
from html import escape as _esc
from zoneinfo import ZoneInfo
from .config import settings
_OSLO = ZoneInfo("Europe/Oslo")
_NORWEGIAN_MONTHS = [
"januar", "februar", "mars", "april", "mai", "juni",
"juli", "august", "september", "oktober", "november", "desember",
]
# Glyffer for de to 5-veis retningsfeltene (2026-08-13, ADR-065 "fullt
# scorekort"-utvidelsen -- bruker ba eksplisitt om at e-postens tabell skal
# se ut som selve Scorekort-siden, ikke bare inneholde samme tall). Samme
# betydning som FairwayIcon/ApproachIcon i frontend/components/round-
# scorecard.tsx, portert til tekst-glyffer + inline farge siden e-post-
# klienter ikke kan antas å vise SVG-er pent. (glyff, farge)-par.
_GREEN = "#1f6b08"
_ORANGE = "#ff5722"
_MUTED = "#9aa39a"
_FAIRWAY_GLYPHS = {"fairway": ("", _GREEN), "left": ("", _ORANGE), "right": ("", _ORANGE)}
_APPROACH_GLYPHS = {
"hit": ("", _GREEN), "long": ("", _ORANGE), "short": ("", _ORANGE),
"left": ("", _ORANGE), "right": ("", _ORANGE),
}
def _signed(value: int) -> str:
if value == 0:
return "E"
return f"+{value}" if value > 0 else str(value)
def _classify(diff: int) -> str:
if diff <= -2:
return "eagle"
if diff == -1:
return "birdie"
if diff == 0:
return "par"
if diff == 1:
return "bogey"
return "double"
def _format_round_datetime(played_at: date, started_at: datetime | None) -> str:
"""`round.played_at` er kun en DATO i skjemaet (ingen klokkeslett lagret
-- "Utslagstid" i ny-runde-veiviseren fanges opp av wizard-state, men
sendes reelt aldri til backend, oppdaget 2026-08-13 under denne
utvidelsen -- egen, separat feil, ikke rettet her). `started_at`
(tidspunktet runden faktisk ble startet, timestamptz) er derfor eneste
ekte klokkeslett å vise -- utelates helt når det mangler (f.eks. en
runde som aldri ble startet ennå) i stedet for å dikte opp et."""
today = datetime.now(_OSLO).date()
date_part = "i dag" if played_at == today else f"{played_at.day}. {_NORWEGIAN_MONTHS[played_at.month - 1]} {played_at.year}"
if started_at is None:
return date_part
return f"{date_part} klokken {started_at.astimezone(_OSLO).strftime('%H:%M')}"
def _format_duration(total_minutes: int) -> str:
hours, minutes = divmod(total_minutes, 60)
return f"{minutes} min" if hours == 0 else f"{hours}t {minutes}min"
def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = settings.FROM_EMAIL
msg["To"] = to_email
msg.set_content(body)
# html_body er valgfritt (2026-08-03, rundeoppsummering til midlertidige
# spillere -- FØRSTE HTML-e-post i appen). add_alternative() gjør
# meldingen multipart/alternative: body over forblir en ekte tekst-
# fallback for klienter uten HTML-støtte, html_body er det klienter
# faktisk viser når de kan.
if html_body is not None:
msg.add_alternative(html_body, subtype="html")
if settings.SMTP_PORT == 465:
with smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
smtp.login(settings.SMTP_USER, settings.SMTP_PASS)
smtp.send_message(msg)
else:
with smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
smtp.starttls()
smtp.login(settings.SMTP_USER, settings.SMTP_PASS)
smtp.send_message(msg)
_TEMPLATES = {
"nb": {
"subject": "Logg inn på TeeCup",
"body": (
"Hei,\n\n"
"Åpne denne lenken for å logge inn på TeeCup innen {minutes} minutter:\n\n"
"{link}\n\n"
"Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n"
"{token}\n\n"
"Ba du ikke om dette, kan du se bort fra e-posten.\n"
),
},
"en": {
"subject": "Log in to TeeCup",
"body": (
"Hi,\n\n"
"Open this link to log in to TeeCup within {minutes} minutes:\n\n"
"{link}\n\n"
"Link not working? Paste this code in manually instead:\n"
"{token}\n\n"
"If you didn't request this, you can ignore this email.\n"
),
},
}
async def send_magic_link_email(to_email: str, raw_token: str, locale: str = "nb") -> None:
"""Sender en klikkbar innloggingslenke (pluss selve koden som fallback).
`locale` valideres allerede av Pydantic (Literal["nb","en"]) på vei inn i
routeren, men faller trygt tilbake til "nb" her også hvis den skulle
komme fra et annet kall uten den garantien.
"""
template = _TEMPLATES.get(locale, _TEMPLATES["nb"])
subject = template["subject"]
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
body = template["body"].format(
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token, link=link
)
await to_thread(_send_sync, to_email, subject, body)
_TWO_FACTOR_TEMPLATES = {
"nb": {
"subject": "Din engangskode for TeeCup",
"body": (
"Hei,\n\n"
"Engangskoden din er: {code}\n\n"
"Den er gyldig i 5 minutter. Ba du ikke om dette, kan du se bort fra e-posten.\n"
),
},
"en": {
"subject": "Your TeeCup one-time code",
"body": (
"Hi,\n\n"
"Your one-time code is: {code}\n\n"
"It is valid for 5 minutes. If you didn't request this, you can ignore this email.\n"
),
},
}
async def send_two_factor_code_email(to_email: str, code: str, locale: str = "nb") -> None:
"""ADR-021 Beslutning C: e-post-2FA-engangskode. Samme leveringsmønster
som magic-link (smtplib i egen tråd)."""
template = _TWO_FACTOR_TEMPLATES.get(locale, _TWO_FACTOR_TEMPLATES["nb"])
body = template["body"].format(code=code)
await to_thread(_send_sync, to_email, template["subject"], body)
_INVITATION_TEMPLATES = {
"nb": {
"subject": "Du er invitert til {org} på TeeCup",
"body": (
"Hei,\n\n"
"{inviter} har invitert deg til å bli {role} i {org} på TeeCup.\n\n"
"Logg inn med denne e-postadressen på {link} for å godta invitasjonen "
"-- den kobles automatisk til kontoen din ved innlogging.\n"
),
},
"en": {
"subject": "You're invited to {org} on TeeCup",
"body": (
"Hi,\n\n"
"{inviter} has invited you to become {role} of {org} on TeeCup.\n\n"
"Log in with this email address at {link} to accept the invitation "
"-- it's linked to your account automatically on login.\n"
),
},
}
_ROLE_LABELS_NB = {"owner": "eier", "admin": "administrator", "member": "medlem"}
_ROLE_LABELS_EN = {"owner": "an owner", "admin": "an administrator", "member": "a member"}
async def send_organization_invitation_email(
to_email: str, org_name: str, role: str, inviter_name: str, locale: str = "nb"
) -> None:
"""ADR-022 Beslutning B: ingen klikkbar aksept-lenke -- invitasjonen
godtas automatisk ved neste innlogging med matchende e-post (samme
mønster som spiller-e-post-kobling, ADR-017)."""
template = _INVITATION_TEMPLATES.get(locale, _INVITATION_TEMPLATES["nb"])
role_label = (_ROLE_LABELS_NB if locale == "nb" else _ROLE_LABELS_EN).get(role, role)
subject = template["subject"].format(org=org_name)
body = template["body"].format(
inviter=inviter_name, role=role_label, org=org_name, link=settings.PUBLIC_BASE_URL
)
await to_thread(_send_sync, to_email, subject, body)
_EMAIL_CHANGE_TEMPLATES = {
"nb": {
"subject": "Bekreft ny e-postadresse for TeeCup",
"body": (
"Hei,\n\n"
"Noen ba om å endre TeeCup-kontoens e-postadresse til denne adressen. "
"Åpne lenken under innen {minutes} minutter for å bekrefte:\n\n"
"{link}\n\n"
"Ba du ikke om dette selv, kan du se bort fra e-posten -- ingenting "
"endres uten at lenken åpnes.\n"
),
},
"en": {
"subject": "Confirm your new TeeCup email address",
"body": (
"Hi,\n\n"
"Someone requested changing this TeeCup account's email address to this "
"address. Open the link below within {minutes} minutes to confirm:\n\n"
"{link}\n\n"
"If you didn't request this yourself, you can ignore this email -- "
"nothing changes unless the link is opened.\n"
),
},
}
async def send_email_change_confirmation(to_email: str, raw_token: str, locale: str = "nb") -> None:
"""ADR-031-oppfølging: e-post er innloggings-identifikatoren, kan derfor
ikke endres med en enkel PATCH -- den NYE adressen må bevise eierskap
FØR selve kontoen endres. Samme token-i-lenke-mønster som magic-link."""
template = _EMAIL_CHANGE_TEMPLATES.get(locale, _EMAIL_CHANGE_TEMPLATES["nb"])
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}"
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
await to_thread(_send_sync, to_email, template["subject"], body)
_SECONDARY_EMAIL_TEMPLATES = {
"nb": {
"subject": "Bekreft ekstra e-postadresse for TeeCup",
"body": (
"Hei,\n\n"
"Noen la til denne adressen som en EKSTRA (sekundær) e-postadresse på "
"en TeeCup-konto -- kontoens hovedadresse endres IKKE. Åpne lenken "
"under innen {minutes} minutter for å bekrefte at du eier denne "
"adressen:\n\n"
"{link}\n\n"
"Ba du ikke om dette selv, kan du se bort fra e-posten -- ingenting "
"legges til uten at lenken åpnes.\n"
),
},
"en": {
"subject": "Confirm an additional TeeCup email address",
"body": (
"Hi,\n\n"
"Someone added this address as an ADDITIONAL (secondary) email on a "
"TeeCup account -- the account's primary address is NOT changed. Open "
"the link below within {minutes} minutes to confirm you own this "
"address:\n\n"
"{link}\n\n"
"If you didn't request this yourself, you can ignore this email -- "
"nothing is added unless the link is opened.\n"
),
},
}
async def send_secondary_email_verification(to_email: str, raw_token: str, locale: str = "nb") -> None:
"""Én person, flere e-postadresser (notert i FEATURE_BACKLOG.md) --
kun det enkle tilfellet (fri, ukrevd adresse). Samme
token-i-lenke-mønster som send_email_change_confirmation, egen mal
siden budskapet er reelt forskjellig ("legges til" vs. "endres til")."""
template = _SECONDARY_EMAIL_TEMPLATES.get(locale, _SECONDARY_EMAIL_TEMPLATES["nb"])
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}&kind=secondary"
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
await to_thread(_send_sync, to_email, template["subject"], body)
_ACCOUNT_MERGE_TEMPLATES = {
"nb": {
"subject": "Bekreft kontosammenslåing på TeeCup",
"body": (
"Hei,\n\n"
"{initiator_name} ({initiator_email}) har bedt om å slå SIN "
"TeeCup-konto sammen med DENNE kontoen. Alt fra denne kontoen "
"(organisasjoner, spiller-koblinger, runder, venner) flyttes "
"over til den andre kontoen, og denne kontoen slettes deretter "
"-- dette kan IKKE angres.\n\n"
"Åpne lenken under innen {minutes} minutter for å se nøyaktig "
"hva som slås sammen og bekrefte:\n\n"
"{link}\n\n"
"Ba du (eller noen andre) ikke om dette selv, kan du se bort "
"fra e-posten -- ingenting slås sammen uten at lenken åpnes OG "
"bekreftes eksplisitt.\n"
),
},
"en": {
"subject": "Confirm account merge on TeeCup",
"body": (
"Hi,\n\n"
"{initiator_name} ({initiator_email}) has requested to merge "
"THEIR TeeCup account with THIS account. Everything from this "
"account (organizations, player links, rounds, friends) will "
"move to the other account, and this account will then be "
"deleted -- this CANNOT be undone.\n\n"
"Open the link below within {minutes} minutes to see exactly "
"what will be merged and confirm:\n\n"
"{link}\n\n"
"If you (or someone else) didn't request this yourself, you "
"can ignore this email -- nothing is merged unless the link is "
"opened AND explicitly confirmed.\n"
),
},
}
async def send_account_merge_confirmation(
to_email: str, raw_token: str, initiator_name: str, initiator_email: str, locale: str = "nb"
) -> None:
"""Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) --
sendes til MÅL-kontoens (taperens) e-post, beviser eierskap av DEN
kontoen (ikke bare at initiativtakeren fortsatt er innlogget), samme
prinsipp som send_email_change_confirmation. Selve utførelsen skjer
IKKE ved lenke-åpning alene -- kun en forhåndsvisning vises, en
eksplisitt bekreftelse kreves i tillegg (se app/account_merge.py)."""
template = _ACCOUNT_MERGE_TEMPLATES.get(locale, _ACCOUNT_MERGE_TEMPLATES["nb"])
link = f"{settings.PUBLIC_BASE_URL}/kontosammenslaing/{raw_token}"
body = template["body"].format(
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES,
link=link,
initiator_name=initiator_name,
initiator_email=initiator_email,
)
await to_thread(_send_sync, to_email, template["subject"], body)
_NOTIFICATION_TEMPLATES = {
"nb": {
"subject": "Nytt varsel fra TeeCup",
"body": (
"Hei,\n\n"
"{message}\n\n"
"Se det i TeeCup:\n{link}\n\n"
"Du får denne e-posten fordi du har valgt å motta e-post for "
"denne typen varsler. Du kan endre dette når som helst under "
"Konto i TeeCup.\n"
),
},
"en": {
"subject": "New notification from TeeCup",
"body": (
"Hi,\n\n"
"{message}\n\n"
"View it in TeeCup:\n{link}\n\n"
"You're receiving this because you opted in to email for this "
"notification type. You can change this anytime under Account "
"in TeeCup.\n"
),
},
}
async def send_notification_email(to_email: str, message: str, link_path: str, locale: str = "nb") -> None:
"""E-post-fallback for det in-app varslingssenteret (FEATURE_BACKLOG.md,
2026-07-28 oppfølging) -- kun sendt når mottakeren selv har valgt inn
for akkurat DENNE varseltypen (`user_notification_email_pref`,
migrasjon 033), se `create_notification()` i
`app/routers/notifications.py`. `message` er allerede en ferdig, norsk
tekst (samme snapshot-prinsipp som selve in-app-varselet) -- kun
ramme-teksten (emne/hilsen/lenkeforklaring) er tospråklig her, ikke
selve budskapet."""
template = _NOTIFICATION_TEMPLATES.get(locale, _NOTIFICATION_TEMPLATES["nb"])
link = f"{settings.PUBLIC_BASE_URL}{link_path}"
body = template["body"].format(message=message, link=link)
await to_thread(_send_sync, to_email, template["subject"], body)
_SESSION_INVITATION_TEMPLATES = {
"nb": {
"subject": "Din runde i {tournament} er registrert i TeeCup",
"body": (
"Hei {name},\n\n"
"{summary}\n\n"
"Åpne lenken under innen {minutes} minutter for å logge deg rett inn og se mer, "
"eller følge med videre:\n\n"
"{link}\n\n"
"Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n"
"{token}\n\n"
"Fikk du denne uventet? Arrangøren av turneringen har registrert deg som spiller "
"i TeeCup -- se bort fra e-posten hvis dette ikke stemmer.\n"
),
},
"en": {
"subject": "Your round in {tournament} has been recorded in TeeCup",
"body": (
"Hi {name},\n\n"
"{summary}\n\n"
"Open the link below within {minutes} minutes to log straight in and see more, "
"or keep following along:\n\n"
"{link}\n\n"
"Link not working? Paste this code in manually instead:\n"
"{token}\n\n"
"Got this unexpectedly? The tournament organizer registered you as a player in "
"TeeCup -- ignore this email if that's not right.\n"
),
},
}
async def send_session_result_email(
to_email: str, player_name: str, tournament_name: str, summary: str, raw_token: str, locale: str = "nb"
) -> None:
"""FEATURE_BACKLOG.md "Midlertidige spillere + automatisk etter-runde-
invitasjon" (avklart 2026-07-28, økt-nivå) -- en organisator sender
dette til spillere UTEN egen konto ennå (player.user_id IS NULL) etter
en økt. `summary` er en allerede ferdig, norsk tekst (samme snapshot-
prinsipp som varsel-meldinger) som beskriver matchresultatet -- bygget
av kalleren (app/routers/tournaments.py), ikke her. Samme ekte
klikkbare magic-link-mønster som send_magic_link_email (ikke bare en
"logg inn senere"-henvisning som organisasjonsinvitasjonen, siden
dette er en engangs, direkte innloggingslenke)."""
template = _SESSION_INVITATION_TEMPLATES.get(locale, _SESSION_INVITATION_TEMPLATES["nb"])
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
subject = template["subject"].format(tournament=tournament_name)
body = template["body"].format(
name=player_name,
summary=summary,
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES,
link=link,
token=raw_token,
)
await to_thread(_send_sync, to_email, subject, body)
async def send_new_account_alert_email(to_email: str, new_user_email: str, display_name: str) -> None:
"""Driftsvarsel til appens eier (2026-07-30) hver gang en HELT NY
TeeCup-konto opprettes (app/routers/auth.py sin verify_magic_link --
den ENESTE plassen en app_user-rad noensinne settes inn). Alltid
norsk -- et internt driftsvarsel til én fast, kjent mottaker, ikke en
brukervendt i18n-tekst."""
subject = "Ny TeeCup-konto opprettet"
body = (
f"Hei,\n\n"
f"En ny konto ble nettopp opprettet i TeeCup:\n\n"
f"E-post: {new_user_email}\n"
f"Navn: {display_name}\n"
)
await to_thread(_send_sync, to_email, subject, body)
@dataclass(frozen=True)
class RoundSummaryHole:
hole_number: int
par: int
stroke_index: int
score: int | None
picked_up: bool
net: int | None
# Stableford (2 poeng for netto par, +/-1 per slag, gulvet på 0) --
# samme formel som frontend/components/round-scorecard.tsx sin
# stablefordPoints(), portert til Python siden e-posten bygges
# server-side. None når netto ikke er kjent (samme betingelse som net).
points: int | None
# Feltene under (2026-08-13, ADR-065 "fullt scorekort"-utvidelsen) er
# ALLE valgfrie og graderer seg naturlig -- None for delt-ball-hull
# (ADR-039 Beslutning C, ingen individuell detalj der) og for felt
# spilleren ikke sporet (stat_level). send_round_summary_email viser
# kun en kolonne når MINST ett hull faktisk har data for den, akkurat
# som round-scorecard.tsx sine show*-flagg.
putts: int | None
tee_shot_result: str | None
approach_result: str | None
chip_count: int | None
bunker_shot_count: int | None
penalty_strokes: int | None
anyway_strokes: int | None
async def send_round_summary_email(
to_email: str,
guest_first_name: str,
round_label: str,
played_at: date,
started_at: datetime | None,
duration_minutes: int | None,
other_players_count: int,
start_hole: int,
holes: list[RoundSummaryHole],
stat_lines: list[tuple[str, str]],
raw_token: str | None,
is_linked_account: bool = False,
round_id: str | None = None,
) -> None:
"""Rundeoppsummering til en spiller med registrert e-post, sendt
UMIDDELBART når DENNE spilleren avsluttes (app/routers/rounds.py sin
update_participant, migrasjon 066, 2026-08-13 -- opprinnelig kun ved hele
rundens fullføring, complete_round, 2026-08-03). FØRSTE HTML-e-post i
appen -- multipart/alternative via _send_sync sin nye html_body-
parameter, med en ekte tekst-fallback.
To mottakertyper (migrasjon 066 utvidet fra kun gjester):
- Gjest (`is_linked_account=False`): `raw_token` er et ekte magic-link-
innloggingstoken (samme mønster som tournaments.py sin
send_scorecard_invitations) -- ingen konto finnes ennå, e-posten
pitcher én.
- Lenket bruker (`is_linked_account=True`): `raw_token` er None,
`round_id` MÅ være satt -- lenken peker rett på rundesiden (vanlig
innlogging gjelder), ingen konto-pitch (feil for noen som allerede
har en).
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
mottakeren -- KUN fornavn i hilsenen, aldri fullt navn.
`stat_lines` er allerede ferdig utledet og formatert av kalleren (samme
"bygg teksten i routeren, ikke i e-post-modulen"-mønster som
send_scorecard_invitations/send_session_result_email) -- graderer seg
naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke
finnes data for. `holes` graderer seg på samme vis kolonne for kolonne
(2026-08-13, ADR-065 "fullt scorekort"-utvidelsen, se RoundSummaryHole).
`played_at` (dato) + `started_at` (klokkeslett, valgfritt) brukes til
hilsen-linjens dato/klokkeslett, formatert til Europe/Oslo -- appen er
norsk-only, ingen lagret tidssone-preferanse å slå opp her. Se
_format_round_datetime for hvorfor disse er to separate felt.
`duration_minutes` er None når verken denne deltakeren eller runden har
en kjent sluttid ennå (kalleren avgjør -- se
_send_participant_round_summary i rounds.py).
`other_players_count` er resten av flighten, brukt i "sammen med N
andre"-leddet (utelatt helt hvis 0). `start_hole` avgjør Ut/Inn-
rekkefølgen i HTML-tabellen (spillerekkefølge, ikke rå hole_number --
matcher round-scorecard.tsx sin holeOrder-logikk).
Alltid norsk, samme presedens som send_scorecard_invitations (verken en
gjest eller en spiller midt i en runde har nødvendigvis en lagret
språkpreferanse tilgjengelig her)."""
if is_linked_account:
link = f"{settings.PUBLIC_BASE_URL}/my-rounds/{round_id}"
else:
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
subject = f"Scorekortet ditt fra {round_label}"
holes_played = sum(1 for h in holes if h.score is not None)
detail_bits = [f"du spilte {holes_played} hull"]
if duration_minutes is not None:
detail_bits[0] += f"{_format_duration(duration_minutes)}"
if other_players_count > 0:
detail_bits.append(f"sammen med {other_players_count} andre")
datetime_phrase = _format_round_datetime(played_at, started_at)
detail_txt = ", ".join(detail_bits)
greeting_line = f"Her er scorekortet ditt fra {round_label}, {datetime_phrase} ({detail_txt}):"
greeting_html = (
f"Her er scorekortet ditt fra <strong>{_esc(round_label)}</strong>, {_esc(datetime_phrase)} "
f"({_esc(detail_txt)}):"
)
# Kolonnesettet graderer seg -- en kolonne tas kun med når MINST ett
# hull faktisk har data for den, akkurat som show*-flaggene i
# frontend/components/round-scorecard.tsx. Samme spesifikasjon brukes
# til BÅDE tekst- og HTML-varianten under, så de aldri kan divergere.
def _gir(h: RoundSummaryHole) -> bool:
return h.score is not None and h.putts is not None and h.score - h.putts <= h.par - 2
columns: list[tuple[str, Callable[[RoundSummaryHole], str]]] = [
("Hcp", lambda h: str(h.stroke_index)),
("Par", lambda h: str(h.par)),
("Slag", lambda h: "PU" if h.picked_up else (str(h.score) if h.score is not None else "")),
]
if any(h.net is not None for h in holes):
columns.append(("Netto", lambda h: str(h.net) if h.net is not None else ""))
if any(h.points is not None for h in holes):
columns.append(("Poeng", lambda h: str(h.points) if h.points is not None else ""))
if any(h.tee_shot_result is not None for h in holes):
columns.append(("Fw", lambda h: _FAIRWAY_GLYPHS[h.tee_shot_result][0] if h.par != 3 and h.tee_shot_result is not None else ""))
if any(h.putts is not None for h in holes):
columns.append(("Putt", lambda h: str(h.putts) if h.putts is not None else ""))
if any(h.score is not None and h.putts is not None for h in holes):
columns.append(("GIR", lambda h: "Ja" if _gir(h) else ""))
if any(h.approach_result is not None for h in holes):
columns.append(("Innspill", lambda h: _APPROACH_GLYPHS[h.approach_result][0] if h.approach_result is not None else ""))
if any(h.chip_count is not None for h in holes):
columns.append(("Chip", lambda h: str(h.chip_count) if h.chip_count is not None else ""))
if any(h.bunker_shot_count is not None for h in holes):
columns.append(("Bunker", lambda h: str(h.bunker_shot_count) if h.bunker_shot_count is not None else ""))
if any(h.penalty_strokes is not None for h in holes):
columns.append(("Straff", lambda h: str(h.penalty_strokes) if h.penalty_strokes is not None else ""))
if any(h.anyway_strokes is not None for h in holes):
columns.append(("Anyway", lambda h: str(h.anyway_strokes) if h.anyway_strokes is not None else ""))
text_lines = [
f"Hei {guest_first_name},",
"",
greeting_line,
"",
]
for h in holes:
cell_txt = ", ".join(f"{label} {fn(h)}" for label, fn in columns)
text_lines.append(f"Hull {h.hole_number}: {cell_txt}")
text_lines.append("")
for label, value in stat_lines:
text_lines.append(f"{label}: {value}")
if is_linked_account:
text_lines += [
"",
"Se hele runden:",
link,
]
else:
text_lines += [
"",
"Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- "
"du trenger ingen konto for å se dette scorekortet, men med én kan du "
"føre score selv, følge egen statistikk og handicap-utvikling over tid.",
"",
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):",
link,
"",
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.",
]
body = "\n".join(text_lines)
# HTML-tabellen (2026-08-13, samme dag som ovenstående -- bruker sendte
# et skjermbilde av selve Scorekort-siden i appen og ba om at e-posten
# skal SE UT SOM DEN, ikke bare inneholde de samme tallene): hull som
# KOLONNER (ikke rader, ulikt tekst-fallbacken over) i to 9-hulls-
# blokker (Ut/Inn) for en 18-hullsrunde, matcher `ScoreBlock` i
# frontend/components/round-scorecard.tsx radrekkefølge/-utvalg
# nøyaktig -- samme farger/glyffer/UT-INN-oppsummeringskolonne.
show_net = any(h.net is not None for h in holes)
show_points = any(h.points is not None for h in holes)
show_fairway = any(h.tee_shot_result is not None for h in holes)
show_putts = any(h.putts is not None for h in holes)
show_gir = any(h.score is not None and h.putts is not None for h in holes)
show_approach = any(h.approach_result is not None for h in holes)
show_chip = any(h.chip_count is not None for h in holes)
show_bunker = any(h.bunker_shot_count is not None for h in holes)
show_penalty = any(h.penalty_strokes is not None for h in holes)
show_anyway = any(h.anyway_strokes is not None for h in holes)
_label_style = (
"padding:5px 6px;border:1px solid #e4ebe3;text-align:left;font-size:10px;"
"font-weight:800;color:#5b665b;white-space:nowrap;background-color:#f6f8f5;"
)
_cell_style = "padding:5px 3px;border:1px solid #e4ebe3;text-align:center;font-size:11px;"
_sum_style = _cell_style + "font-weight:800;background-color:#eef4ec;"
def _row_html(label: str, cell_htmls: list[str], summary_html: str) -> str:
cells = "".join(f'<td style="{_cell_style}">{c}</td>' for c in cell_htmls)
return f'<tr><th style="{_label_style}">{_esc(label)}</th>{cells}<td style="{_sum_style}">{summary_html}</td></tr>'
def _score_mark_html(h: RoundSummaryHole) -> str:
if h.picked_up:
return f'<span style="display:inline-block;min-width:20px;padding:1px 3px;border:2px solid {_ORANGE};border-radius:4px;color:{_ORANGE};font-size:9px;font-weight:800;">PU</span>'
if h.score is None:
return f'<span style="color:{_MUTED};"></span>'
kind = _classify(h.score - h.par)
base = "display:inline-block;min-width:20px;padding:1px 3px;font-weight:800;font-size:12px;"
style = {
"eagle": base + f"border-radius:999px;background-color:{_GREEN};color:#ffffff;",
"birdie": base + f"border-radius:999px;border:2px solid {_GREEN};color:{_GREEN};",
"bogey": base + f"border-radius:4px;border:2px solid {_ORANGE};color:{_ORANGE};",
"double": base + f"border-radius:4px;background-color:{_ORANGE};color:#ffffff;",
}.get(kind, base + "color:#012c11;")
return f'<span style="{style}">{h.score}</span>'
def _fw_html(h: RoundSummaryHole) -> str:
if h.par == 3 or h.tee_shot_result is None:
return f'<span style="color:{_MUTED};"></span>'
glyph, color = _FAIRWAY_GLYPHS[h.tee_shot_result]
return f'<span style="color:{color};font-weight:800;">{glyph}</span>'
def _gir_html(h: RoundSummaryHole) -> str:
if h.score is None or h.putts is None:
return f'<span style="color:{_MUTED};"></span>'
return f'<span style="color:{_GREEN};font-weight:800;">✓</span>' if _gir(h) else f'<span style="color:{_MUTED};"></span>'
def _approach_html(h: RoundSummaryHole) -> str:
if h.approach_result is None:
return f'<span style="color:{_MUTED};"></span>'
glyph, color = _APPROACH_GLYPHS[h.approach_result]
return f'<span style="color:{color};font-weight:800;">{glyph}</span>'
def _count_or_dash(values: list[int]) -> str:
return str(sum(values)) if values else ""
def _build_block_html(block_holes: list[RoundSummaryHole], summary_label: str) -> str:
header_cells = "".join(
f'<td style="padding:5px 3px;border:1px solid #10331a;text-align:center;font-size:11px;font-weight:800;color:#ffffff;background-color:#1a4325;">{h.hole_number}</td>'
for h in block_holes
)
header = (
f'<tr><th style="padding:5px 6px;border:1px solid #10331a;text-align:left;font-size:10px;font-weight:800;color:#ffffff;background-color:#1a4325;">Hull</th>'
f"{header_cells}"
f'<td style="padding:5px 3px;border:1px solid #10331a;text-align:center;font-size:9px;font-weight:800;color:#ffffff;background-color:{_GREEN};text-transform:uppercase;">{_esc(summary_label)}</td></tr>'
)
rows = [header]
rows.append(_row_html("Hcp", [str(h.stroke_index) for h in block_holes], ""))
rows.append(_row_html("Par", [str(h.par) for h in block_holes], str(sum(h.par for h in block_holes))))
played = [h.score for h in block_holes if h.score is not None]
rows.append(_row_html("Scr", [_score_mark_html(h) for h in block_holes], _count_or_dash(played)))
if show_net:
nets = [h.net for h in block_holes if h.net is not None]
rows.append(_row_html("Net", [str(h.net) if h.net is not None else "" for h in block_holes], _count_or_dash(nets)))
if show_points:
points = [h.points for h in block_holes if h.points is not None]
rows.append(_row_html("Pnt", [str(h.points) if h.points is not None else "" for h in block_holes], _count_or_dash(points)))
if show_fairway:
eligible = [h for h in block_holes if h.par != 3 and h.tee_shot_result is not None]
hits = [1 for h in eligible if h.tee_shot_result == "fairway"]
rows.append(_row_html("Fw", [_fw_html(h) for h in block_holes], _count_or_dash(hits) if eligible else ""))
if show_putts:
putts = [h.putts for h in block_holes if h.putts is not None]
rows.append(_row_html("Putt", [str(h.putts) if h.putts is not None else "" for h in block_holes], _count_or_dash(putts)))
if show_gir:
eligible = [h for h in block_holes if h.score is not None and h.putts is not None]
hits = [1 for h in eligible if _gir(h)]
rows.append(_row_html("GIR", [_gir_html(h) for h in block_holes], _count_or_dash(hits) if eligible else ""))
if show_approach:
rows.append(_row_html("Appr.", [_approach_html(h) for h in block_holes], ""))
if show_chip:
vals = [h.chip_count for h in block_holes if h.chip_count is not None]
rows.append(_row_html("Chip", [str(h.chip_count) if h.chip_count is not None else "" for h in block_holes], _count_or_dash(vals)))
if show_bunker:
vals = [h.bunker_shot_count for h in block_holes if h.bunker_shot_count is not None]
rows.append(_row_html("Bnk", [str(h.bunker_shot_count) if h.bunker_shot_count is not None else "" for h in block_holes], _count_or_dash(vals)))
if show_penalty:
vals = [h.penalty_strokes for h in block_holes if h.penalty_strokes is not None]
rows.append(_row_html("Str", [str(h.penalty_strokes) if h.penalty_strokes is not None else "" for h in block_holes], _count_or_dash(vals)))
if show_anyway:
vals = [h.anyway_strokes for h in block_holes if h.anyway_strokes is not None]
rows.append(_row_html("Any", [str(h.anyway_strokes) if h.anyway_strokes is not None else "" for h in block_holes], _count_or_dash(vals)))
return f'<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;width:100%;table-layout:fixed;">{"".join(rows)}</table>'
# Ut/Inn-rekkefølgen følger SPILLEREKKEFØLGEN (start_hole-relativ, samme
# sirkulære logikk som round-scorecard.tsx sin holeOrder), ikke rå
# hole_number -- kun forskjell fra fysisk 1-9/10-18 for en runde som
# ikke starter på hull 1 (uvanlig, men strukturelt mulig).
holes_by_number = {h.hole_number: h for h in holes}
hole_order = [((start_hole - 1 + i) % 18) + 1 for i in range(len(holes))]
ordered_holes = [holes_by_number[n] for n in hole_order if n in holes_by_number]
front, back = ordered_holes[:9], ordered_holes[9:18]
blocks_html = _build_block_html(front, "Tot" if not back else "Ut")
if back:
blocks_html += _build_block_html(back, "Inn")
total_par = sum(h.par for h in holes)
played_holes = [h for h in holes if h.score is not None]
total_gross = sum(h.score for h in played_holes) if played_holes else None
played_par = sum(h.par for h in played_holes) if played_holes else 0
total_points = sum(h.points for h in holes if h.points is not None) if show_points else None
def _tile_html(label: str, value: str, color: str = "#012c11") -> str:
return f"""<td style="padding:10px 2px;text-align:center;border:1px solid #e4ebe3;border-radius:10px;">
<div style="font-size:20px;font-weight:800;color:{color};">{_esc(value)}</div>
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.03em;color:#767f75;margin-top:2px;">{_esc(label)}</div>
</td>"""
tiles = [
_tile_html("Par", str(total_par)),
_tile_html("Score", str(total_gross) if total_gross is not None else ""),
_tile_html("Til par", _signed(total_gross - played_par) if total_gross is not None else "", _ORANGE),
]
if show_points:
tiles.append(_tile_html("Poeng", str(total_points) if total_points is not None else "", _GREEN))
tiles_html = (
'<table role="presentation" cellpadding="0" cellspacing="0" width="100%" '
'style="border-collapse:separate;border-spacing:4px 0;margin:12px 0 20px;"><tr>'
+ "".join(tiles) + "</tr></table>"
)
if is_linked_account:
pitch_html = ""
cta_label = "Se hele runden"
footer_html = ""
else:
pitch_html = f"""<p style="margin:0 0 20px;font-size:14px;color:#424941;">
Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen
konto for å se dette scorekortet, men med én kan du føre score selv, følge egen
statistikk og handicap-utvikling over tid.
</p>"""
cta_label = "Logg inn og se hele runden"
footer_html = f"""<p style="margin:20px 0 0;font-size:12px;color:#767f75;">
Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne
e-posten -- den brukes ikke til noe annet.
</p>"""
html_body = f"""\
<!doctype html>
<html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f6f2;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="100%" style="max-width:480px;background-color:#ffffff;border-radius:16px;overflow:hidden;">
<tr><td style="background-color:#1a4325;padding:20px 24px;">
<span style="color:#ffffff;font-size:20px;font-weight:800;">TeeCup</span>
</td></tr>
<tr><td style="padding:24px;">
<p style="margin:0 0 16px;font-size:16px;">Hei {_esc(guest_first_name)},</p>
<p style="margin:0 0 20px;font-size:16px;">{greeting_html}</p>
<div style="overflow-x:auto;">
{blocks_html}
</div>
{tiles_html}
{pitch_html}
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;">
<a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px;
font-weight:700;text-decoration:none;">{cta_label}</a>
</td></tr></table>
{footer_html}
</td></tr>
</table>
</td></tr>
</table>
</body></html>
"""
await to_thread(_send_sync, to_email, subject, body, html_body)