Opgave 9: admin-ordreoversigt + "markér betalt"

GET /orders (filtre: purchase_round_id, route_id, payment_status)
giver admin en oversigt pr. ordre med deltagerinfo, vinlinjer og
beregnet total. POST /orders/{id}/mark-paid registrerer betalingen
(payment_status=paid, paid_at) og udløser mail-event #3
(payment_confirmed) — afviser med 409 hvis ordren allerede er betalt.

Udtrukket render/send/log-logikken fra 8a's _send_order_confirmation
til en delt app/services/order_mail.py::send_order_event_mail,
parameteriseret over MailEventType, så order_confirmed og
payment_confirmed ikke længere duplikerer den samme kode. Som med
order_confirmed er mail-afsendelsen best-effort: en manglende
skabelon eller afsenderkonfiguration blokerer aldrig selve
betalingsregistreringen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-29 00:03:45 +02:00
parent 18f1c6cbac
commit 3592fbf845
6 changed files with 271 additions and 112 deletions

View file

@ -102,7 +102,10 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
- **Order** — deltager + runde
- `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at`
- Oprettes via den offentlige bestillings-API (opgave 8a/8b, ingen
login); admin-ordreoversigt + "markér betalt" er stadig opgave 9
login). Admin-ordreoversigt + "markér betalt" (opgave 9): `GET
/orders` (filtre: `purchase_round_id`, `route_id`,
`payment_status`) og `POST /orders/{id}/mark-paid` — se
Mail-events #3 nedenfor for selve betalingsbekræftelsen
- **OrderLine** — ordre + wine_offering + antal
- Samme som Order — skrives af det offentlige bestillings-endpoint
@ -178,24 +181,29 @@ udleder "hvilken rute/runde gælder denne request".
2. Ordre afgivet → kvittering til deltager. **Implementeret** (opgave
8a, omlagt i 8b): `POST /public/orders` (ingen login, ingen
`route_id` i URL'en — ruten afgøres af `Host`-headeren, se Route
ovenfor) genbruger 7b's render/send/log-mønster for én deltager.
Manglende skabelon blokerer bevidst **ikke** bestillingen (kun
`/announce`'s admin-flow fejler hårdt på det) — en konfigurationsfejl
må aldrig stoppe en rigtig kundes ordre. Kvitteringen genskaber
bevidst det gamle systems fulde vinkatalog-layout (efterligner den
fysiske bestillingsseddel ved vinbonden). *Notifikation til admin
ved ny ordre findes ikke endnu.*
3. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail
til deltager (opgave 9 — bygger videre på samme mønster)
ovenfor). Kvitteringen genskaber bevidst det gamle systems fulde
vinkatalog-layout (efterligner den fysiske bestillingsseddel ved
vinbonden). *Notifikation til admin ved ny ordre findes ikke
endnu.*
3. Admin markerer ordre "betalt" → automatisk kvitteringsmail til
deltager. **Implementeret** (opgave 9): `POST
/orders/{id}/mark-paid` sætter `payment_status=paid` +
`paid_at`, og forsøger derefter at sende `payment_confirmed`-mailen.
Event #3 er modelleret i `MailEventType` (`payment_confirmed`) og kan
allerede have en skabelon oprettet via 7a's CRUD, men selve
udløsningen (fra "markér betalt"-handlingen i opgave 9) er ikke bygget
endnu.
Event #2 og #3 deler nu samme render/send/log-logik via
`app/services/order_mail.py::send_order_event_mail` (udtrukket fra
8a's oprindelige lokale helper, da den ville være blevet duplikeret en
gang til). Fælles for begge: en manglende skabelon eller manglende
`sender_name`/`sender_email` på ruten blokerer **ikke** selve
handlingen (ordre-oprettelsen hhv. betalings-registreringen) — kun
`/announce`'s admin-flow (event #1) fejler hårdt på det. En
konfigurationsfejl må aldrig forhindre en rigtig kundes ordre eller en
admins betalingsregistrering; mail-forsøget er altid best-effort og
logges/advares om i stedet.
## Fase 1 — nuværende scope
**Færdige opgaver (1-7, 8a-c):**
**Færdige opgaver (1-9):**
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
@ -234,9 +242,15 @@ endnu.
skærmbillede under planlægning), og efterfølgende browser-testet
og justeret af brugeren (se "Den offentlige side" ovenfor for de
to fund/rettelser).
9. ✅ Admin-ordreoversigt + "markér betalt" — se Order og Mail-events
#3 ovenfor. `GET /orders` (filtre: `purchase_round_id`, `route_id`,
`payment_status`) returnerer en admin-visning pr. ordre med
deltagernavn/-email/-telefon, vinlinjer (navn+pris) og
`total_price_eur`. `POST /orders/{id}/mark-paid` er en almindelig
admin-handling (ikke superadmin-eleveret — hverken destruktiv eller
irreversibel) der afviser med `409` hvis ordren allerede er betalt.
**Resterende opgaver:**
9. Admin-ordreoversigt pr. runde med "markér betalt" → trigger mail #3
10. Genskab afhentningsliste (HTML-udtræk til print) mod ny datamodel
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
12. Testkør en rigtig runde gennem hele flowet

View file

@ -7,6 +7,7 @@ from sqlmodel import Session, select
from app.core.security import decode_access_token
from app.db import SessionDep
from app.models.mail_template import MailTemplate
from app.models.order import Order
from app.models.participant import Participant
from app.models.purchase_round import PurchaseRound
from app.models.route import Route
@ -85,6 +86,18 @@ def get_round_in_organization(session: Session, round_id: int, organization_id:
return purchase_round
def get_order_in_organization(session: Session, order_id: int, organization_id: int) -> Order:
order = session.exec(
select(Order)
.join(PurchaseRound)
.join(Route)
.where(Order.id == order_id, Route.organization_id == organization_id)
).first()
if order is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found")
return order
def get_wine_offering_in_organization(session: Session, wine_offering_id: int, organization_id: int) -> WineOffering:
wine_offering = session.exec(
select(WineOffering)

View file

@ -7,6 +7,7 @@ from app.routers import (
auth,
mail_logs,
mail_templates,
orders,
participants,
public_orders,
public_site,
@ -25,6 +26,7 @@ app.include_router(wine_offerings.router)
app.include_router(wine_categories.router)
app.include_router(mail_templates.router)
app.include_router(mail_logs.router)
app.include_router(orders.router)
app.include_router(webhooks.router)
app.include_router(public_orders.router)
app.include_router(public_site.router)

113
app/routers/orders.py Normal file
View file

@ -0,0 +1,113 @@
import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy.orm import selectinload
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import CurrentUser, get_order_in_organization
from app.models.mail_template import MailEventType
from app.models.order import Order, OrderPublic, PaymentStatus
from app.models.order_line import OrderLine, OrderLinePublic
from app.models.purchase_round import PurchaseRound
from app.models.route import Route
from app.services.order_mail import send_order_event_mail
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/orders", tags=["orders"])
class OrderLineAdminView(OrderLinePublic):
wine_name: str
unit_price: Decimal
class OrderAdminView(OrderPublic):
participant_name: str
participant_email: str
participant_phone: Optional[str]
order_lines: list[OrderLineAdminView]
total_price_eur: Decimal
def _to_admin_view(order: Order) -> OrderAdminView:
order_lines = [
OrderLineAdminView(
id=ol.id,
order_id=ol.order_id,
wine_offering_id=ol.wine_offering_id,
quantity=ol.quantity,
wine_name=ol.wine_offering.name,
unit_price=ol.wine_offering.price,
)
for ol in order.order_lines
]
total_price_eur = sum((ol.unit_price * ol.quantity for ol in order_lines), Decimal("0"))
return OrderAdminView(
**OrderPublic.model_validate(order).model_dump(),
participant_name=order.participant.name,
participant_email=order.participant.email,
participant_phone=order.participant.phone,
order_lines=order_lines,
total_price_eur=total_price_eur,
)
@router.get("", response_model=list[OrderAdminView])
def list_orders(
session: SessionDep,
current_user: CurrentUser,
purchase_round_id: Optional[int] = None,
route_id: Optional[int] = None,
payment_status: Optional[PaymentStatus] = None,
limit: int = Query(default=100, le=500, gt=0),
offset: int = Query(default=0, ge=0),
) -> list[OrderAdminView]:
statement = (
select(Order)
.join(PurchaseRound)
.join(Route)
.where(Route.organization_id == current_user.organization_id)
.options(selectinload(Order.participant), selectinload(Order.order_lines).selectinload(OrderLine.wine_offering))
)
if purchase_round_id is not None:
statement = statement.where(Order.purchase_round_id == purchase_round_id)
if route_id is not None:
statement = statement.where(PurchaseRound.route_id == route_id)
if payment_status is not None:
statement = statement.where(Order.payment_status == payment_status)
statement = statement.order_by(Order.id.desc()).offset(offset).limit(limit)
orders = session.exec(statement).all()
return [_to_admin_view(order) for order in orders]
@router.post("/{order_id}/mark-paid", response_model=OrderAdminView)
def mark_order_paid(order_id: int, session: SessionDep, current_user: CurrentUser) -> OrderAdminView:
order = get_order_in_organization(session, order_id, current_user.organization_id)
if order.payment_status == PaymentStatus.PAID:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Order is already marked as paid")
order.payment_status = PaymentStatus.PAID
order.paid_at = datetime.now(timezone.utc)
session.add(order)
session.commit()
session.refresh(order)
try:
send_order_event_mail(
session,
order.purchase_round.route,
order.purchase_round,
order.participant,
order,
order.order_lines,
MailEventType.PAYMENT_CONFIRMED,
)
except Exception:
logger.exception("Failed to send payment_confirmed mail for order %s", order.id)
return _to_admin_view(order)

View file

@ -1,5 +1,5 @@
import logging
from datetime import datetime, timezone
from datetime import datetime
from decimal import Decimal
from typing import Optional
@ -10,23 +10,14 @@ from sqlalchemy.orm import selectinload
from sqlmodel import Field, Session, SQLModel, select
from app.db import SessionDep
from app.models.mail_log import MailLog, MailLogStatus
from app.models.mail_template import MailEventType, MailTemplate
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.mail_rendering import (
CatalogRow,
compute_order_total_eur,
format_currency,
format_datetime_da,
render_order_receipt_html,
render_template,
)
from app.services.postal import PostalSendError, send_mail
from app.services.order_mail import send_order_event_mail
logger = logging.getLogger(__name__)
@ -233,89 +224,6 @@ def signup(payload: PublicSignupInput, request: Request, session: SessionDep) ->
return participant
def _send_order_confirmation(
session: Session,
route: Route,
purchase_round: PurchaseRound,
participant: Participant,
order: Order,
order_lines: list[OrderLine],
) -> None:
template = session.exec(
select(MailTemplate).where(
MailTemplate.route_id == route.id, MailTemplate.event_type == MailEventType.ORDER_CONFIRMED
)
).first()
if template is None:
logger.warning("No order_confirmed template for route %s — skipping confirmation mail", route.id)
return
if not route.sender_name or not route.sender_email:
logger.warning("Route %s has no sender_name/sender_email — skipping confirmation mail", route.id)
return
all_offerings = session.exec(
select(WineOffering)
.where(WineOffering.purchase_round_id == purchase_round.id)
.options(selectinload(WineOffering.category))
).all()
all_offerings_sorted = sorted(all_offerings, key=lambda o: (o.category.sort_order, o.id))
ordered_qty_by_offering = {ol.wine_offering_id: ol.quantity for ol in order_lines}
catalog_rows = [
CatalogRow(
category_name=o.category.name,
wine_name=o.name,
unit_price_eur=o.price,
ordered_quantity=ordered_qty_by_offering.get(o.id),
)
for o in all_offerings_sorted
]
total_eur = compute_order_total_eur(catalog_rows)
total_dkk = total_eur * purchase_round.eur_dkk_rate if purchase_round.eur_dkk_rate else None
variables = {
"participant_name": participant.name,
"route_name": route.name,
"round_name": purchase_round.name,
"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 "",
"pickup_info_text": purchase_round.pickup_info_text or "",
"order_lines_html": render_order_receipt_html(catalog_rows),
"order_total_eur": format_currency(total_eur),
"order_total_dkk": format_currency(total_dkk) if total_dkk is not None else "",
}
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.ORDER_CONFIRMED,
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="order_confirmed",
)
log.status = MailLogStatus.SENT
log.postal_message_id = postal_id
log.postal_token = postal_token
log.sent_at = datetime.now(timezone.utc)
except PostalSendError as exc:
log.error_message = str(exc)
session.add(log)
session.commit()
@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)
@ -353,7 +261,9 @@ def submit_order(payload: PublicOrderSubmission, request: Request, session: Sess
session.refresh(order)
try:
_send_order_confirmation(session, route, purchase_round, participant, order, order_lines)
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)

107
app/services/order_mail.py Normal file
View file

@ -0,0 +1,107 @@
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select
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
from app.models.route import Route
from app.models.wine_offering import WineOffering
from app.services.mail_rendering import (
CatalogRow,
compute_order_total_eur,
format_currency,
format_datetime_da,
render_order_receipt_html,
render_template,
)
from app.services.postal import PostalSendError, send_mail
logger = logging.getLogger(__name__)
def send_order_event_mail(
session: Session,
route: Route,
purchase_round: PurchaseRound,
participant: Participant,
order: Order,
order_lines: list[OrderLine],
event_type: MailEventType,
) -> None:
template = session.exec(
select(MailTemplate).where(MailTemplate.route_id == route.id, MailTemplate.event_type == event_type)
).first()
if template is None:
logger.warning("No %s template for route %s — skipping mail", event_type.value, route.id)
return
if not route.sender_name or not route.sender_email:
logger.warning("Route %s has no sender_name/sender_email — skipping mail", route.id)
return
all_offerings = session.exec(
select(WineOffering)
.where(WineOffering.purchase_round_id == purchase_round.id)
.options(selectinload(WineOffering.category))
).all()
all_offerings_sorted = sorted(all_offerings, key=lambda o: (o.category.sort_order, o.id))
ordered_qty_by_offering = {ol.wine_offering_id: ol.quantity for ol in order_lines}
catalog_rows = [
CatalogRow(
category_name=o.category.name,
wine_name=o.name,
unit_price_eur=o.price,
ordered_quantity=ordered_qty_by_offering.get(o.id),
)
for o in all_offerings_sorted
]
total_eur = compute_order_total_eur(catalog_rows)
total_dkk = total_eur * purchase_round.eur_dkk_rate if purchase_round.eur_dkk_rate else None
variables = {
"participant_name": participant.name,
"route_name": route.name,
"round_name": purchase_round.name,
"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 "",
"pickup_info_text": purchase_round.pickup_info_text or "",
"order_lines_html": render_order_receipt_html(catalog_rows),
"order_total_eur": format_currency(total_eur),
"order_total_dkk": format_currency(total_dkk) if total_dkk is not None else "",
}
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=event_type,
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=event_type.value,
)
log.status = MailLogStatus.SENT
log.postal_message_id = postal_id
log.postal_token = postal_token
log.sent_at = datetime.now(timezone.utc)
except PostalSendError as exc:
log.error_message = str(exc)
session.add(log)
session.commit()