vinindkoeb/app/routers/purchase_rounds.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

268 lines
11 KiB
Python

from datetime import datetime, timezone
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select
from app.db import SessionDep
from app.dependencies import (
CurrentUser,
get_round_in_organization,
get_route_in_organization,
oauth2_scheme,
require_elevated_superuser,
)
from app.models.mail_log import MailLog, MailLogStatus
from app.models.mail_template import MailEventType, MailTemplate
from app.models.order import Order
from app.models.order_line import OrderLine
from app.models.participant import Participant
from app.models.purchase_round import (
PurchaseRound,
PurchaseRoundCreate,
PurchaseRoundPublic,
PurchaseRoundStatus,
PurchaseRoundUpdate,
)
from app.models.route import Route
from app.models.wine_offering import WineOffering
from app.services.mail_rendering import format_datetime_da, render_template
from app.services.postal import PostalSendError, send_mail
router = APIRouter(prefix="/purchase-rounds", tags=["purchase-rounds"])
templates = Jinja2Templates(directory="app/templates")
def _commit_or_conflict(session: Session) -> None:
try:
session.commit()
except IntegrityError as exc:
session.rollback()
if "ck_purchase_round_dates_required_unless_draft" in str(getattr(exc, "orig", exc)):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="opens_at, order_deadline_at and pickup_at are required unless status is draft",
) from exc
raise
@router.post("", response_model=PurchaseRoundPublic, status_code=status.HTTP_201_CREATED)
def create_purchase_round(
payload: PurchaseRoundCreate, session: SessionDep, current_user: CurrentUser
) -> PurchaseRound:
get_route_in_organization(session, payload.route_id, current_user.organization_id)
purchase_round = PurchaseRound.model_validate(payload)
purchase_round.status = PurchaseRoundStatus.DRAFT # nye runder starter altid som kladde
session.add(purchase_round)
_commit_or_conflict(session)
session.refresh(purchase_round)
return purchase_round
@router.get("", response_model=list[PurchaseRoundPublic])
def list_purchase_rounds(
session: SessionDep,
current_user: CurrentUser,
route_id: Optional[int] = None,
status_: Optional[PurchaseRoundStatus] = Query(default=None, alias="status"),
limit: int = Query(default=100, le=500, gt=0),
offset: int = Query(default=0, ge=0),
) -> list[PurchaseRound]:
statement = select(PurchaseRound).join(Route).where(Route.organization_id == current_user.organization_id)
if route_id is not None:
statement = statement.where(PurchaseRound.route_id == route_id)
if status_ is not None:
statement = statement.where(PurchaseRound.status == status_)
statement = statement.order_by(PurchaseRound.id.desc()).offset(offset).limit(limit)
return list(session.exec(statement).all())
@router.get("/{round_id}", response_model=PurchaseRoundPublic)
def get_purchase_round(round_id: int, session: SessionDep, current_user: CurrentUser) -> PurchaseRound:
return get_round_in_organization(session, round_id, current_user.organization_id)
@router.patch("/{round_id}", response_model=PurchaseRoundPublic)
def update_purchase_round(
round_id: int, payload: PurchaseRoundUpdate, session: SessionDep, current_user: CurrentUser
) -> PurchaseRound:
purchase_round = get_round_in_organization(session, round_id, current_user.organization_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(purchase_round, field, value)
session.add(purchase_round)
_commit_or_conflict(session)
session.refresh(purchase_round)
return purchase_round
@router.delete("/{round_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_purchase_round(
round_id: int,
session: SessionDep,
current_user: CurrentUser,
token: Annotated[str, Depends(oauth2_scheme)],
) -> None:
purchase_round = get_round_in_organization(session, round_id, current_user.organization_id)
if purchase_round.status == PurchaseRoundStatus.OPEN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="An open purchase round cannot be deleted")
if purchase_round.status == PurchaseRoundStatus.CLOSED:
require_elevated_superuser(current_user, token)
session.delete(purchase_round)
session.commit()
@router.post("/{source_round_id}/copy", response_model=PurchaseRoundPublic, status_code=status.HTTP_201_CREATED)
def copy_purchase_round(source_round_id: int, session: SessionDep, current_user: CurrentUser) -> PurchaseRound:
source = get_round_in_organization(session, source_round_id, current_user.organization_id)
new_round = PurchaseRound(
name=source.name,
status=PurchaseRoundStatus.DRAFT,
opens_at=source.opens_at,
order_deadline_at=source.order_deadline_at,
pickup_at=source.pickup_at,
eur_dkk_rate=source.eur_dkk_rate,
intro_text=source.intro_text,
pickup_info_text=source.pickup_info_text,
route_id=source.route_id,
)
session.add(new_round)
session.flush() # for at få new_round.id til vinudbuddene nedenfor
for offering in source.wine_offerings:
session.add(
WineOffering(
name=offering.name,
price=offering.price,
is_organic=offering.is_organic,
category_id=offering.category_id,
purchase_round_id=new_round.id,
)
)
_commit_or_conflict(session)
session.refresh(new_round)
return new_round
@router.post("/{round_id}/announce")
def announce_purchase_round(round_id: int, session: SessionDep, current_user: CurrentUser) -> dict:
purchase_round = get_round_in_organization(session, round_id, current_user.organization_id)
if purchase_round.status != PurchaseRoundStatus.OPEN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Round must be open before announcing")
route = purchase_round.route
if not route.sender_name or not route.sender_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Route has no sender_name/sender_email configured",
)
template = session.exec(
select(MailTemplate).where(
MailTemplate.route_id == route.id, MailTemplate.event_type == MailEventType.ROUND_ANNOUNCED
)
).first()
if template is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No round_announced template configured for this route",
)
participants = session.exec(
select(Participant).where(Participant.route_id == route.id, Participant.is_active == True) # noqa: E712
).all()
sent = failed = 0
for participant in participants:
variables = {
"participant_name": participant.name,
"route_name": route.name,
"round_name": purchase_round.name,
"opens_at": format_datetime_da(purchase_round.opens_at) if purchase_round.opens_at else "",
"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 "",
"intro_text": purchase_round.intro_text or "",
"pickup_info_text": purchase_round.pickup_info_text or "",
}
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.ROUND_ANNOUNCED,
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="round_announced",
)
log.status = MailLogStatus.SENT
log.postal_message_id = postal_id
log.postal_token = postal_token
log.sent_at = datetime.now(timezone.utc)
sent += 1
except PostalSendError as exc:
log.error_message = str(exc)
failed += 1
session.add(log)
session.commit()
return {"sent": sent, "failed": failed, "total_participants": len(participants)}
@router.get("/{round_id}/pickup-list", response_class=HTMLResponse)
def get_pickup_list(round_id: int, request: Request, session: SessionDep, current_user: CurrentUser) -> HTMLResponse:
purchase_round = get_round_in_organization(session, round_id, current_user.organization_id)
orders = session.exec(
select(Order)
.where(Order.purchase_round_id == round_id)
.options(
selectinload(Order.participant),
selectinload(Order.order_lines).selectinload(OrderLine.wine_offering).selectinload(WineOffering.category),
)
.order_by(Order.order_number)
).all()
rows = []
for order in orders:
lines_sorted = sorted(
order.order_lines, key=lambda ol: (ol.wine_offering.category.sort_order, ol.wine_offering.id)
)
lines_by_category: dict[str, list[dict]] = {}
total_bottles = 0
for ol in lines_sorted:
lines_by_category.setdefault(ol.wine_offering.category.name, []).append(
{"quantity": ol.quantity, "wine_name": ol.wine_offering.name}
)
total_bottles += ol.quantity
rows.append(
{
"order_number": order.order_number,
"participant_name": order.participant.name,
"participant_phone": order.participant.phone,
"participant_email": order.participant.email,
"total_bottles": total_bottles,
"total_cases": f"{total_bottles / 6:.2f}",
"categories": list(lines_by_category.items()),
}
)
return templates.TemplateResponse(
request,
"pickup_list.html",
{"route_name": purchase_round.route.name, "round_name": purchase_round.name, "orders": rows},
)