Add public order API (task 8a) — no login, ad-hoc participant creation

POST /public/routes/{id}/orders and GET .../current-round are the
first fully unauthenticated routes in the app — no CurrentUser or
org-scoping helper applies, route_id comes straight from the URL.

Participant identification matches the old system's no-login design:
an existing participant (case-insensitive email match on the route) is
reused, and — per explicit user correction — its name/phone/is_active
are overwritten from the submission every time, since there's no login
or "edit my details" page; the order form *is* how members keep their
contact info current. is_active is a checkbox on the form itself
(default true), not inferred. A new email always creates a new
participant row — that's the deliberate no-login way to "change
email". An IntegrityError race on concurrent same-email submissions
falls back to re-selecting the winner rather than 500ing.

Order confirmation email reuses 7b's render/send/log pattern for a
single participant, but a missing order_confirmed template (or missing
route sender identity) is a silent no-op + logger.warning, never a
blocking error — an admin configuration gap must never stop a real
order, unlike task 7b's admin-triggered /announce which fails fast on
the same conditions.

Per explicit user correction, the confirmation email deliberately
reproduces the old system's full-catalog receipt (every wine in the
round, grouped by category, ordered quantity filled in where
applicable) — confirmed via old-emails/Kvitering.eml discussion to be
a deliberate mimicry of the physical order sheet used when buying wine
at the producer's cellar, not a legacy-template artifact to simplify
away. Currency totals are computed at full Decimal precision (EUR
summed, then × eur_dkk_rate with no intermediate rounding) and only
rounded to 2dp (ROUND_HALF_UP) at final display formatting, per
correction — avoids compounding an early rounding error into the DKK
figure.

Empty order_lines is rejected with 400 (not the Pydantic-level 422 a
schema constraint would give) — the old empty-order signup/unsubscribe
hack is intentionally not resurrected here; a real signup/unsubscribe
flow is a separate future task per the user's own framing.

Verified end-to-end directly against the real route 4: a temporary
open round + wine offerings + order_confirmed template, a real order
submitted and a real confirmation email sent/logged, a second order
with the same (differently-cased) email confirmed to update the
existing participant in place rather than duplicate it, a
different-round wine_offering_id rejected (400), an empty order
rejected (400 not 422), and the missing-template case confirmed to
still return 201 with no new MailLog row. All temporary data removed
afterward; the real participant (carsten@itkon.dk, id 133) — legitimately
touched by the get-or-create-with-update logic during testing, exactly
as designed — was restored to its original name/phone/is_active. The
other 307 real participants were untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-28 22:08:58 +02:00
parent 53a5138d45
commit 706c6d5828
5 changed files with 370 additions and 0 deletions

View file

@ -7,6 +7,7 @@ from app.routers import (
mail_logs,
mail_templates,
participants,
public_orders,
purchase_rounds,
webhooks,
wine_categories,
@ -22,6 +23,7 @@ app.include_router(wine_categories.router)
app.include_router(mail_templates.router)
app.include_router(mail_logs.router)
app.include_router(webhooks.router)
app.include_router(public_orders.router)
@app.get("/health")

View file

@ -6,6 +6,8 @@ from sqlalchemy import Column
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel
from app.models.order_line import OrderLinePublic
if TYPE_CHECKING:
from app.models.order_line import OrderLine
from app.models.participant import Participant
@ -46,3 +48,13 @@ class Order(OrderBase, table=True):
order_lines: List["OrderLine"] = Relationship(
back_populates="order", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
class OrderPublic(OrderBase):
id: int
participant_id: int
purchase_round_id: int
class OrderConfirmation(OrderPublic):
order_lines: List[OrderLinePublic]

View file

@ -22,3 +22,9 @@ class OrderLine(OrderLineBase, table=True):
order: "Order" = Relationship(back_populates="order_lines")
wine_offering: "WineOffering" = Relationship(back_populates="order_lines")
class OrderLinePublic(OrderLineBase):
id: int
order_id: int
wine_offering_id: int

View file

@ -0,0 +1,301 @@
import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, HTTPException, status
from pydantic import field_validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlmodel import Field, Session, SQLModel, select
from app.db import SessionDep
from app.models.mail_log import MailLog, MailLogStatus
from app.models.mail_template import MailEventType, MailTemplate
from app.models.order import Order, OrderConfirmation, OrderPublic
from app.models.order_line import OrderLine, OrderLinePublic
from app.models.participant import Participant, _normalize_email
from app.models.purchase_round import PurchaseRound, PurchaseRoundStatus
from app.models.route import Route
from app.models.wine_offering import WineOffering
from app.services.mail_rendering import (
CatalogRow,
compute_order_total_eur,
format_currency,
format_datetime_da,
render_order_receipt_html,
render_template,
)
from app.services.postal import PostalSendError, send_mail
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/public", tags=["public"])
class PublicParticipantInput(SQLModel):
name: str
email: str
phone: str
is_active: bool = True
@field_validator("email")
@classmethod
def _validate_email(cls, v: str) -> str:
return _normalize_email(v)
class PublicOrderLineInput(SQLModel):
wine_offering_id: int
quantity: int = Field(gt=0)
class PublicOrderSubmission(SQLModel):
participant: PublicParticipantInput
order_lines: list[PublicOrderLineInput]
class PublicWineCategory(SQLModel):
id: int
name: str
sort_order: int
class PublicWineOffering(SQLModel):
id: int
name: str
price: Decimal
is_organic: bool
category: PublicWineCategory
class PublicRoundInfo(SQLModel):
route_id: int
route_name: str
meeting_info: Optional[str]
round_id: int
round_name: str
opens_at: Optional[datetime]
order_deadline_at: Optional[datetime]
pickup_at: Optional[datetime]
intro_text: Optional[str]
pickup_info_text: Optional[str]
eur_dkk_rate: Optional[Decimal]
wine_offerings: list[PublicWineOffering]
def _get_open_round(session: Session, route_id: int) -> Optional[PurchaseRound]:
return session.exec(
select(PurchaseRound)
.where(PurchaseRound.route_id == route_id, PurchaseRound.status == PurchaseRoundStatus.OPEN)
.order_by(PurchaseRound.id.desc())
).first()
def _apply_participant_data(participant: Participant, data: PublicParticipantInput) -> None:
participant.name = data.name
participant.phone = data.phone
participant.is_active = data.is_active
def _get_or_create_participant(session: Session, route_id: int, data: PublicParticipantInput) -> Participant:
existing = session.exec(
select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
).first()
if existing is not None:
_apply_participant_data(existing, data)
session.add(existing)
return existing
participant = Participant(
name=data.name, email=data.email, phone=data.phone, is_active=data.is_active, route_id=route_id
)
session.add(participant)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
if "uq_participant_route_email" in str(getattr(exc, "orig", exc)):
existing = session.exec(
select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
).first()
if existing is not None:
_apply_participant_data(existing, data)
session.add(existing)
return existing
raise
return participant
@router.get("/routes/{route_id}/current-round", response_model=PublicRoundInfo)
def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo:
route = session.get(Route, route_id)
if route is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found")
purchase_round = _get_open_round(session, route_id)
if purchase_round is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route")
offerings = session.exec(
select(WineOffering)
.where(WineOffering.purchase_round_id == purchase_round.id)
.options(selectinload(WineOffering.category))
).all()
return PublicRoundInfo(
route_id=route.id,
route_name=route.name,
meeting_info=route.meeting_info,
round_id=purchase_round.id,
round_name=purchase_round.name,
opens_at=purchase_round.opens_at,
order_deadline_at=purchase_round.order_deadline_at,
pickup_at=purchase_round.pickup_at,
intro_text=purchase_round.intro_text,
pickup_info_text=purchase_round.pickup_info_text,
eur_dkk_rate=purchase_round.eur_dkk_rate,
wine_offerings=[
PublicWineOffering(
id=o.id,
name=o.name,
price=o.price,
is_organic=o.is_organic,
category=PublicWineCategory(
id=o.category.id, name=o.category.name, sort_order=o.category.sort_order
),
)
for o in offerings
],
)
def _send_order_confirmation(
session: Session,
route: Route,
purchase_round: PurchaseRound,
participant: Participant,
order: Order,
order_lines: list[OrderLine],
) -> None:
template = session.exec(
select(MailTemplate).where(
MailTemplate.route_id == route.id, MailTemplate.event_type == MailEventType.ORDER_CONFIRMED
)
).first()
if template is None:
logger.warning("No order_confirmed template for route %s — skipping confirmation mail", route.id)
return
if not route.sender_name or not route.sender_email:
logger.warning("Route %s has no sender_name/sender_email — skipping confirmation mail", route.id)
return
all_offerings = session.exec(
select(WineOffering)
.where(WineOffering.purchase_round_id == purchase_round.id)
.options(selectinload(WineOffering.category))
).all()
all_offerings_sorted = sorted(all_offerings, key=lambda o: (o.category.sort_order, o.id))
ordered_qty_by_offering = {ol.wine_offering_id: ol.quantity for ol in order_lines}
catalog_rows = [
CatalogRow(
category_name=o.category.name,
wine_name=o.name,
unit_price_eur=o.price,
ordered_quantity=ordered_qty_by_offering.get(o.id),
)
for o in all_offerings_sorted
]
total_eur = compute_order_total_eur(catalog_rows)
total_dkk = total_eur * purchase_round.eur_dkk_rate if purchase_round.eur_dkk_rate else None
variables = {
"participant_name": participant.name,
"route_name": route.name,
"round_name": purchase_round.name,
"order_deadline_at": format_datetime_da(purchase_round.order_deadline_at)
if purchase_round.order_deadline_at
else "",
"pickup_at": format_datetime_da(purchase_round.pickup_at) if purchase_round.pickup_at else "",
"pickup_info_text": purchase_round.pickup_info_text or "",
"order_lines_html": render_order_receipt_html(catalog_rows),
"order_total_eur": format_currency(total_eur),
"order_total_dkk": format_currency(total_dkk) if total_dkk is not None else "",
}
rendered_subject = render_template(template.subject, variables)
rendered_body = render_template(template.body_html, variables)
log = MailLog(
participant_id=participant.id,
purchase_round_id=purchase_round.id,
mail_template_id=template.id,
event_type=MailEventType.ORDER_CONFIRMED,
rendered_subject=rendered_subject,
status=MailLogStatus.FAILED,
)
try:
postal_id, postal_token = send_mail(
to=participant.email,
from_name=route.sender_name,
from_email=route.sender_email,
subject=rendered_subject,
html_body=rendered_body,
tag="order_confirmed",
)
log.status = MailLogStatus.SENT
log.postal_message_id = postal_id
log.postal_token = postal_token
log.sent_at = datetime.now(timezone.utc)
except PostalSendError as exc:
log.error_message = str(exc)
session.add(log)
session.commit()
@router.post("/routes/{route_id}/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED)
def submit_order(route_id: int, payload: PublicOrderSubmission, session: SessionDep) -> OrderConfirmation:
route = session.get(Route, route_id)
if route is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found")
purchase_round = _get_open_round(session, route_id)
if purchase_round is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route")
if not payload.order_lines:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Order must contain at least one line")
requested_ids = {line.wine_offering_id for line in payload.order_lines}
offerings = session.exec(
select(WineOffering).where(
WineOffering.id.in_(requested_ids), WineOffering.purchase_round_id == purchase_round.id
)
).all()
offerings_by_id = {o.id: o for o in offerings}
missing = requested_ids - offerings_by_id.keys()
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"wine_offering_id(s) not part of the current round: {sorted(missing)}",
)
participant = _get_or_create_participant(session, route_id, payload.participant)
order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id)
session.add(order)
session.flush()
order_lines = [
OrderLine(order_id=order.id, wine_offering_id=line.wine_offering_id, quantity=line.quantity)
for line in payload.order_lines
]
session.add_all(order_lines)
session.commit()
session.refresh(order)
try:
_send_order_confirmation(session, route, purchase_round, participant, order, order_lines)
except Exception:
logger.exception("Failed to send order_confirmed mail for order %s", order.id)
return OrderConfirmation(
**OrderPublic.model_validate(order).model_dump(),
order_lines=[OrderLinePublic.model_validate(ol) for ol in order_lines],
)

View file

@ -1,5 +1,8 @@
import re
from dataclasses import dataclass
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional
_PLACEHOLDER_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
@ -10,3 +13,49 @@ def render_template(text: str, variables: dict[str, str]) -> str:
def format_datetime_da(dt: datetime) -> str:
return dt.strftime("%d-%m-%Y kl. %H:%M")
@dataclass
class CatalogRow:
category_name: str
wine_name: str
unit_price_eur: Decimal
ordered_quantity: Optional[int] # None = ikke bestilt, vises tomt
def render_order_receipt_html(rows: list[CatalogRow]) -> str:
"""rows skal være for-sorteret efter (kategori.sort_order, vin-id) —
denne funktion grupperer blot konsekutive ens kategorinavne under
én overskrift, ligesom den gamle Kvitering-mail."""
html_parts = ['<table style="border-collapse:collapse;width:100%;">']
current_category = None
for row in rows:
if row.category_name != current_category:
html_parts.append(
f'<tr><th colspan="3" style="text-align:left;padding-top:12px;">{row.category_name}</th></tr>'
)
current_category = row.category_name
qty_display = str(row.ordered_quantity) if row.ordered_quantity else ""
html_parts.append(
f'<tr><td style="padding:2px 8px;width:40px;">{qty_display}</td>'
f'<td style="padding:2px 8px;">{row.wine_name}</td>'
f'<td style="padding:2px 8px;text-align:right;">{row.unit_price_eur:.2f}</td></tr>'
)
html_parts.append("</table>")
return "".join(html_parts)
def compute_order_total_eur(rows: list[CatalogRow]) -> Decimal:
"""Kun de faktisk bestilte linjer tæller med i totalen — resten af
kataloget er kun til visning. Ingen afrunding her (fuld præcision
bevares til evt. DKK-omregning)."""
return sum(
(r.unit_price_eur * r.ordered_quantity for r in rows if r.ordered_quantity),
Decimal("0"),
)
def format_currency(amount: Decimal) -> str:
"""Eneste sted afrunding sker — til visning, normal matematisk
afrunding (ikke Decimal's ROUND_HALF_EVEN-standard)."""
return str(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))