diff --git a/CLAUDE.md b/CLAUDE.md index d99b151..924d6c3 100644 --- a/CLAUDE.md +++ b/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 diff --git a/app/dependencies.py b/app/dependencies.py index a4a21be..fb480a2 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -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) diff --git a/app/main.py b/app/main.py index 2c16a5d..a443331 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/routers/orders.py b/app/routers/orders.py new file mode 100644 index 0000000..cf3011f --- /dev/null +++ b/app/routers/orders.py @@ -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) diff --git a/app/routers/public_orders.py b/app/routers/public_orders.py index 832cff9..e020a48 100644 --- a/app/routers/public_orders.py +++ b/app/routers/public_orders.py @@ -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) diff --git a/app/services/order_mail.py b/app/services/order_mail.py new file mode 100644 index 0000000..5bd2285 --- /dev/null +++ b/app/services/order_mail.py @@ -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()