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>
301 lines
11 KiB
Python
301 lines
11 KiB
Python
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],
|
|
)
|