vinindkoeb/app/routers/public_orders.py
carsten b6b14aefac Opgave 10: fortløbende ordrenummer pr. runde + afhentningsliste
Order.order_number tildeles atomisk i POST /public/orders via en
SELECT ... FOR UPDATE-lås på rundens række + MAX+1, så samtidige
bestillinger lige efter en runde åbner ikke kan kollidere. Nummeret
starter på 1 for hver runde (UNIQUE(purchase_round_id, order_number))
og er nu tilgængeligt som {{order_number}} i mail-skabeloner.

GET /purchase-rounds/{id}/pickup-list genskaber den gamle systems
afhentningsliste (delt af brugeren som reference) mod den nye
datamodel: én printside pr. ordre, sorteret efter order_number (vist
stort i øverste højre hjørne), kontaktinfo, flaske-/kasseantal
(pænt afrundet — originalen viste et urundet float), og
vinlinjer grupperet pr. kategori.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 00:21:45 +02:00

283 lines
9.9 KiB
Python

import logging
from datetime import datetime
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import field_validator
from sqlalchemy import func
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_template import MailEventType
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.order_mail import send_order_event_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
@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)
# Lås rundens række så to samtidige bestillinger ikke kan få samme order_number
session.exec(select(PurchaseRound).where(PurchaseRound.id == purchase_round.id).with_for_update()).one()
next_order_number = (
session.exec(
select(func.max(Order.order_number)).where(Order.purchase_round_id == purchase_round.id)
).first()
or 0
) + 1
order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id, order_number=next_order_number)
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_event_mail(
session, route, purchase_round, participant, order, order_lines, MailEventType.ORDER_CONFIRMED
)
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],
)