vinindkoeb/app/routers/public_orders.py
carsten 609c3efb05 Opgave 8c: ny offentlig bestillings-/tilmeldingsside
Server-renderet af FastAPI selv (Jinja2 + vanilla JS/CSS, intet
build-step) mod det eksisterende domæneopløste JSON-API fra 8b.
GET / viser bestillingssiden når en runde er åben (rundeoverskrift,
intro_text, kategori-grupperet vinkatalog med live-beregnet
EUR/DKK-total, bestillingsformular), ellers en tilmeldingsside.
GET /tilmelding er altid tilgængelig uafhængigt af rundestatus, og
GET /afhentning viser rutens meeting_info. Layoutet er modelleret
efter den gamle sides bestillingsside. get_current_round's
serialiseringslogik er udtrukket til en delt _build_page_info-helper,
så JSON-API'et og de nye HTML-sider genbruger nøjagtig samme
domæne-/rundeopslag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 22:55:55 +02:00

363 lines
13 KiB
Python

import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, 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, ParticipantPublic, _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 PublicSignupInput(SQLModel):
name: str
email: str
@field_validator("email")
@classmethod
def _validate_email(cls, v: str) -> str:
return _normalize_email(v)
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 PublicRoundDetails(SQLModel):
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]
class PublicPageInfo(SQLModel):
route_name: str
meeting_info: Optional[str]
current_round: Optional[PublicRoundDetails]
def get_route_from_domain(request: Request, session: Session) -> Route:
host = request.headers.get("host", "")
domain = host.split(":")[0] # fjern evt. port
route = session.exec(select(Route).where(Route.public_domain == domain)).first()
if route is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No route configured for domain {domain!r}")
return route
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
def _signup_participant(session: Session, route_id: int, data: PublicSignupInput) -> Participant:
existing = session.exec(
select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
).first()
if existing is not None:
existing.name = data.name
existing.is_active = True # tilmelding er en eksplicit "ja tak til mails"-handling
session.add(existing)
return existing
participant = Participant(name=data.name, email=data.email, phone=None, is_active=True, 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:
existing.name = data.name
existing.is_active = True
session.add(existing)
return existing
raise
return participant
def _build_page_info(session: Session, route: Route) -> PublicPageInfo:
purchase_round = _get_open_round(session, route.id)
if purchase_round is None:
return PublicPageInfo(route_name=route.name, meeting_info=route.meeting_info, current_round=None)
offerings = session.exec(
select(WineOffering)
.where(WineOffering.purchase_round_id == purchase_round.id)
.options(selectinload(WineOffering.category))
).all()
offerings = sorted(offerings, key=lambda o: (o.category.sort_order, o.id))
return PublicPageInfo(
route_name=route.name,
meeting_info=route.meeting_info,
current_round=PublicRoundDetails(
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
],
),
)
@router.get("/current-round", response_model=PublicPageInfo)
def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo:
route = get_route_from_domain(request, session)
return _build_page_info(session, route)
@router.post("/signup", response_model=ParticipantPublic, status_code=status.HTTP_201_CREATED)
def signup(payload: PublicSignupInput, request: Request, session: SessionDep) -> Participant:
route = get_route_from_domain(request, session)
participant = _signup_participant(session, route.id, payload)
session.commit()
session.refresh(participant)
return participant
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("/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED)
def submit_order(payload: PublicOrderSubmission, request: Request, session: SessionDep) -> OrderConfirmation:
route = get_route_from_domain(request, session)
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],
)