vinindkoeb/app/routers/purchase_rounds.py
carsten 34ed5c28d7 Add Postal sending, "annoncér runde" action, and MailLog (task 7b)
POST /purchase-rounds/{id}/announce renders the route's round_announced
MailTemplate with simple {{variable}} substitution (app/services/
mail_rendering.py) for every active participant on the route, sends
via Postal's real HTTP API (app/services/postal.py — request/response
shapes verified directly against docs.postalserver.io, not guessed),
and records one MailLog row per attempt (sent/failed, Postal's
message id + token for the future webhook in 7c, denormalized
rendered_subject so the log reflects what was actually sent even if
the template changes later). A failed send for one participant doesn't
abort the rest — each attempt is isolated and committed individually.

Route gained sender_name/sender_email in 7a; both are now populated
for the real route from the historical emails ("Finn Gram - Fælles
Vinindkøb <finn@vinindkoeb.dk>") directly in the DB, since there's no
Route CRUD API yet.

Verified end-to-end against the real Postal instance: a real test
email was sent and received via an isolated throwaway Organization/
Route/Participant/PurchaseRound/MailTemplate sandbox (never the real
route's 308 participants), MailLog captured the correct Postal message
id/token, and a temporarily-invalid API key produced a clean
sent:0/failed:1 result with Postal's actual error message stored,
not a 500. All test data removed afterward; real route/participant
data confirmed untouched.

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

217 lines
8.6 KiB
Python

from datetime import datetime, timezone
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
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.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"])
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)}