vinindkoeb/app/routers/public_orders.py
carsten 7d767ec7c3 Opgave 8b: domænebaseret rute-opløsning + tilmeldings-endpoint
Offentlige endpoints afgør nu ruten via Host-headeren
(Route.public_domain) i stedet for route_id i URL'en, så én
frontend kan betjene flere organisationers domæner via reverse
proxy. GET /public/current-round returnerer altid 200 med
current_round: null når ingen runde er åben (i stedet for 404),
og nyt POST /public/signup lader deltagere tilmelde sig med kun
navn+email uden at røre et eksisterende telefonnummer.

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

358 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
@router.get("/current-round", response_model=PublicPageInfo)
def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo:
route = get_route_from_domain(request, session)
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()
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.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],
)