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>
268 lines
11 KiB
Python
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},
|
|
)
|