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>
217 lines
8.6 KiB
Python
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)}
|