diff --git a/app/main.py b/app/main.py index cb0f4bd..725fca4 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from app.routers import ( mail_logs, mail_templates, participants, + public_orders, purchase_rounds, webhooks, wine_categories, @@ -22,6 +23,7 @@ app.include_router(wine_categories.router) app.include_router(mail_templates.router) app.include_router(mail_logs.router) app.include_router(webhooks.router) +app.include_router(public_orders.router) @app.get("/health") diff --git a/app/models/order.py b/app/models/order.py index 26375ed..caa5395 100644 --- a/app/models/order.py +++ b/app/models/order.py @@ -6,6 +6,8 @@ from sqlalchemy import Column from sqlalchemy import Enum as SAEnum from sqlmodel import Field, Relationship, SQLModel +from app.models.order_line import OrderLinePublic + if TYPE_CHECKING: from app.models.order_line import OrderLine from app.models.participant import Participant @@ -46,3 +48,13 @@ class Order(OrderBase, table=True): order_lines: List["OrderLine"] = Relationship( back_populates="order", sa_relationship_kwargs={"cascade": "all, delete-orphan"} ) + + +class OrderPublic(OrderBase): + id: int + participant_id: int + purchase_round_id: int + + +class OrderConfirmation(OrderPublic): + order_lines: List[OrderLinePublic] diff --git a/app/models/order_line.py b/app/models/order_line.py index 91f82c4..d1122db 100644 --- a/app/models/order_line.py +++ b/app/models/order_line.py @@ -22,3 +22,9 @@ class OrderLine(OrderLineBase, table=True): order: "Order" = Relationship(back_populates="order_lines") wine_offering: "WineOffering" = Relationship(back_populates="order_lines") + + +class OrderLinePublic(OrderLineBase): + id: int + order_id: int + wine_offering_id: int diff --git a/app/routers/public_orders.py b/app/routers/public_orders.py new file mode 100644 index 0000000..4a4d67a --- /dev/null +++ b/app/routers/public_orders.py @@ -0,0 +1,301 @@ +import logging +from datetime import datetime, timezone +from decimal import Decimal +from typing import Optional + +from fastapi import APIRouter, HTTPException, status +from pydantic import field_validator +from sqlalchemy.exc import IntegrityError +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.order import Order, OrderConfirmation, OrderPublic +from app.models.order_line import OrderLine, OrderLinePublic +from app.models.participant import Participant, _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 + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/public", tags=["public"]) + + +class PublicParticipantInput(SQLModel): + name: str + email: str + phone: str + is_active: bool = True + + @field_validator("email") + @classmethod + def _validate_email(cls, v: str) -> str: + return _normalize_email(v) + + +class PublicOrderLineInput(SQLModel): + wine_offering_id: int + quantity: int = Field(gt=0) + + +class PublicOrderSubmission(SQLModel): + participant: PublicParticipantInput + order_lines: list[PublicOrderLineInput] + + +class PublicWineCategory(SQLModel): + id: int + name: str + sort_order: int + + +class PublicWineOffering(SQLModel): + id: int + name: str + price: Decimal + is_organic: bool + category: PublicWineCategory + + +class PublicRoundInfo(SQLModel): + route_id: int + route_name: str + meeting_info: Optional[str] + round_id: int + round_name: str + opens_at: Optional[datetime] + order_deadline_at: Optional[datetime] + pickup_at: Optional[datetime] + intro_text: Optional[str] + pickup_info_text: Optional[str] + eur_dkk_rate: Optional[Decimal] + wine_offerings: list[PublicWineOffering] + + +def _get_open_round(session: Session, route_id: int) -> Optional[PurchaseRound]: + return session.exec( + select(PurchaseRound) + .where(PurchaseRound.route_id == route_id, PurchaseRound.status == PurchaseRoundStatus.OPEN) + .order_by(PurchaseRound.id.desc()) + ).first() + + +def _apply_participant_data(participant: Participant, data: PublicParticipantInput) -> None: + participant.name = data.name + participant.phone = data.phone + participant.is_active = data.is_active + + +def _get_or_create_participant(session: Session, route_id: int, data: PublicParticipantInput) -> Participant: + existing = session.exec( + select(Participant).where(Participant.route_id == route_id, Participant.email == data.email) + ).first() + if existing is not None: + _apply_participant_data(existing, data) + session.add(existing) + return existing + + participant = Participant( + name=data.name, email=data.email, phone=data.phone, is_active=data.is_active, route_id=route_id + ) + session.add(participant) + try: + session.flush() + except IntegrityError as exc: + session.rollback() + if "uq_participant_route_email" in str(getattr(exc, "orig", exc)): + existing = session.exec( + select(Participant).where(Participant.route_id == route_id, Participant.email == data.email) + ).first() + if existing is not None: + _apply_participant_data(existing, data) + session.add(existing) + return existing + raise + return participant + + +@router.get("/routes/{route_id}/current-round", response_model=PublicRoundInfo) +def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo: + route = session.get(Route, route_id) + if route is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found") + purchase_round = _get_open_round(session, route_id) + if purchase_round is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route") + + offerings = session.exec( + select(WineOffering) + .where(WineOffering.purchase_round_id == purchase_round.id) + .options(selectinload(WineOffering.category)) + ).all() + + return PublicRoundInfo( + route_id=route.id, + route_name=route.name, + meeting_info=route.meeting_info, + round_id=purchase_round.id, + round_name=purchase_round.name, + opens_at=purchase_round.opens_at, + order_deadline_at=purchase_round.order_deadline_at, + pickup_at=purchase_round.pickup_at, + intro_text=purchase_round.intro_text, + pickup_info_text=purchase_round.pickup_info_text, + eur_dkk_rate=purchase_round.eur_dkk_rate, + wine_offerings=[ + PublicWineOffering( + id=o.id, + name=o.name, + price=o.price, + is_organic=o.is_organic, + category=PublicWineCategory( + id=o.category.id, name=o.category.name, sort_order=o.category.sort_order + ), + ) + for o in offerings + ], + ) + + +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("/routes/{route_id}/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED) +def submit_order(route_id: int, payload: PublicOrderSubmission, session: SessionDep) -> OrderConfirmation: + route = session.get(Route, route_id) + if route is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found") + purchase_round = _get_open_round(session, route_id) + if purchase_round is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route") + if not payload.order_lines: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Order must contain at least one line") + + requested_ids = {line.wine_offering_id for line in payload.order_lines} + offerings = session.exec( + select(WineOffering).where( + WineOffering.id.in_(requested_ids), WineOffering.purchase_round_id == purchase_round.id + ) + ).all() + offerings_by_id = {o.id: o for o in offerings} + missing = requested_ids - offerings_by_id.keys() + if missing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"wine_offering_id(s) not part of the current round: {sorted(missing)}", + ) + + participant = _get_or_create_participant(session, route_id, payload.participant) + + order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id) + session.add(order) + session.flush() + order_lines = [ + OrderLine(order_id=order.id, wine_offering_id=line.wine_offering_id, quantity=line.quantity) + for line in payload.order_lines + ] + session.add_all(order_lines) + session.commit() + session.refresh(order) + + try: + _send_order_confirmation(session, route, purchase_round, participant, order, order_lines) + except Exception: + logger.exception("Failed to send order_confirmed mail for order %s", order.id) + + return OrderConfirmation( + **OrderPublic.model_validate(order).model_dump(), + order_lines=[OrderLinePublic.model_validate(ol) for ol in order_lines], + ) diff --git a/app/services/mail_rendering.py b/app/services/mail_rendering.py index 71f4e51..e7b9ce1 100644 --- a/app/services/mail_rendering.py +++ b/app/services/mail_rendering.py @@ -1,5 +1,8 @@ import re +from dataclasses import dataclass from datetime import datetime +from decimal import ROUND_HALF_UP, Decimal +from typing import Optional _PLACEHOLDER_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}") @@ -10,3 +13,49 @@ def render_template(text: str, variables: dict[str, str]) -> str: def format_datetime_da(dt: datetime) -> str: return dt.strftime("%d-%m-%Y kl. %H:%M") + + +@dataclass +class CatalogRow: + category_name: str + wine_name: str + unit_price_eur: Decimal + ordered_quantity: Optional[int] # None = ikke bestilt, vises tomt + + +def render_order_receipt_html(rows: list[CatalogRow]) -> str: + """rows skal være for-sorteret efter (kategori.sort_order, vin-id) — + denne funktion grupperer blot konsekutive ens kategorinavne under + én overskrift, ligesom den gamle Kvitering-mail.""" + html_parts = ['
| {row.category_name} | ||
|---|---|---|
| {qty_display} | ' + f'{row.wine_name} | ' + f'{row.unit_price_eur:.2f} |