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
|
- **Order** — deltager + runde
|
||||||
- `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at`
|
- `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at`
|
||||||
- Oprettes via den offentlige bestillings-API (opgave 8a/8b, ingen
|
- 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
|
- **OrderLine** — ordre + wine_offering + antal
|
||||||
- Samme som Order — skrives af det offentlige bestillings-endpoint
|
- 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
|
2. Ordre afgivet → kvittering til deltager. **Implementeret** (opgave
|
||||||
8a, omlagt i 8b): `POST /public/orders` (ingen login, ingen
|
8a, omlagt i 8b): `POST /public/orders` (ingen login, ingen
|
||||||
`route_id` i URL'en — ruten afgøres af `Host`-headeren, se Route
|
`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.
|
ovenfor). Kvitteringen genskaber bevidst det gamle systems fulde
|
||||||
Manglende skabelon blokerer bevidst **ikke** bestillingen (kun
|
vinkatalog-layout (efterligner den fysiske bestillingsseddel ved
|
||||||
`/announce`'s admin-flow fejler hårdt på det) — en konfigurationsfejl
|
vinbonden). *Notifikation til admin ved ny ordre findes ikke
|
||||||
må aldrig stoppe en rigtig kundes ordre. Kvitteringen genskaber
|
endnu.*
|
||||||
bevidst det gamle systems fulde vinkatalog-layout (efterligner den
|
3. Admin markerer ordre "betalt" → automatisk kvitteringsmail til
|
||||||
fysiske bestillingsseddel ved vinbonden). *Notifikation til admin
|
deltager. **Implementeret** (opgave 9): `POST
|
||||||
ved ny ordre findes ikke endnu.*
|
/orders/{id}/mark-paid` sætter `payment_status=paid` +
|
||||||
3. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail
|
`paid_at`, og forsøger derefter at sende `payment_confirmed`-mailen.
|
||||||
til deltager (opgave 9 — bygger videre på samme mønster)
|
|
||||||
|
|
||||||
Event #3 er modelleret i `MailEventType` (`payment_confirmed`) og kan
|
Event #2 og #3 deler nu samme render/send/log-logik via
|
||||||
allerede have en skabelon oprettet via 7a's CRUD, men selve
|
`app/services/order_mail.py::send_order_event_mail` (udtrukket fra
|
||||||
udløsningen (fra "markér betalt"-handlingen i opgave 9) er ikke bygget
|
8a's oprindelige lokale helper, da den ville være blevet duplikeret en
|
||||||
endnu.
|
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
|
## Fase 1 — nuværende scope
|
||||||
|
|
||||||
**Færdige opgaver (1-7, 8a-c):**
|
**Færdige opgaver (1-9):**
|
||||||
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
||||||
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
||||||
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
||||||
|
|
@ -234,9 +242,15 @@ endnu.
|
||||||
skærmbillede under planlægning), og efterfølgende browser-testet
|
skærmbillede under planlægning), og efterfølgende browser-testet
|
||||||
og justeret af brugeren (se "Den offentlige side" ovenfor for de
|
og justeret af brugeren (se "Den offentlige side" ovenfor for de
|
||||||
to fund/rettelser).
|
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:**
|
**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
|
10. Genskab afhentningsliste (HTML-udtræk til print) mod ny datamodel
|
||||||
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
|
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
|
||||||
12. Testkør en rigtig runde gennem hele flowet
|
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.core.security import decode_access_token
|
||||||
from app.db import SessionDep
|
from app.db import SessionDep
|
||||||
from app.models.mail_template import MailTemplate
|
from app.models.mail_template import MailTemplate
|
||||||
|
from app.models.order import Order
|
||||||
from app.models.participant import Participant
|
from app.models.participant import Participant
|
||||||
from app.models.purchase_round import PurchaseRound
|
from app.models.purchase_round import PurchaseRound
|
||||||
from app.models.route import Route
|
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
|
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:
|
def get_wine_offering_in_organization(session: Session, wine_offering_id: int, organization_id: int) -> WineOffering:
|
||||||
wine_offering = session.exec(
|
wine_offering = session.exec(
|
||||||
select(WineOffering)
|
select(WineOffering)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from app.routers import (
|
||||||
auth,
|
auth,
|
||||||
mail_logs,
|
mail_logs,
|
||||||
mail_templates,
|
mail_templates,
|
||||||
|
orders,
|
||||||
participants,
|
participants,
|
||||||
public_orders,
|
public_orders,
|
||||||
public_site,
|
public_site,
|
||||||
|
|
@ -25,6 +26,7 @@ app.include_router(wine_offerings.router)
|
||||||
app.include_router(wine_categories.router)
|
app.include_router(wine_categories.router)
|
||||||
app.include_router(mail_templates.router)
|
app.include_router(mail_templates.router)
|
||||||
app.include_router(mail_logs.router)
|
app.include_router(mail_logs.router)
|
||||||
|
app.include_router(orders.router)
|
||||||
app.include_router(webhooks.router)
|
app.include_router(webhooks.router)
|
||||||
app.include_router(public_orders.router)
|
app.include_router(public_orders.router)
|
||||||
app.include_router(public_site.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
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -10,23 +10,14 @@ from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import Field, Session, SQLModel, select
|
from sqlmodel import Field, Session, SQLModel, select
|
||||||
|
|
||||||
from app.db import SessionDep
|
from app.db import SessionDep
|
||||||
from app.models.mail_log import MailLog, MailLogStatus
|
from app.models.mail_template import MailEventType
|
||||||
from app.models.mail_template import MailEventType, MailTemplate
|
|
||||||
from app.models.order import Order, OrderConfirmation, OrderPublic
|
from app.models.order import Order, OrderConfirmation, OrderPublic
|
||||||
from app.models.order_line import OrderLine, OrderLinePublic
|
from app.models.order_line import OrderLine, OrderLinePublic
|
||||||
from app.models.participant import Participant, ParticipantPublic, _normalize_email
|
from app.models.participant import Participant, ParticipantPublic, _normalize_email
|
||||||
from app.models.purchase_round import PurchaseRound, PurchaseRoundStatus
|
from app.models.purchase_round import PurchaseRound, PurchaseRoundStatus
|
||||||
from app.models.route import Route
|
from app.models.route import Route
|
||||||
from app.models.wine_offering import WineOffering
|
from app.models.wine_offering import WineOffering
|
||||||
from app.services.mail_rendering import (
|
from app.services.order_mail import send_order_event_mail
|
||||||
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -233,89 +224,6 @@ def signup(payload: PublicSignupInput, request: Request, session: SessionDep) ->
|
||||||
return participant
|
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)
|
@router.post("/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED)
|
||||||
def submit_order(payload: PublicOrderSubmission, request: Request, session: SessionDep) -> OrderConfirmation:
|
def submit_order(payload: PublicOrderSubmission, request: Request, session: SessionDep) -> OrderConfirmation:
|
||||||
route = get_route_from_domain(request, session)
|
route = get_route_from_domain(request, session)
|
||||||
|
|
@ -353,7 +261,9 @@ def submit_order(payload: PublicOrderSubmission, request: Request, session: Sess
|
||||||
session.refresh(order)
|
session.refresh(order)
|
||||||
|
|
||||||
try:
|
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:
|
except Exception:
|
||||||
logger.exception("Failed to send order_confirmed mail for order %s", order.id)
|
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