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:
parent
18f1c6cbac
commit
3592fbf845
6 changed files with 271 additions and 112 deletions
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
113
app/routers/orders.py
Normal 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)
|
||||
|
|
@ -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
107
app/services/order_mail.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue