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>
273 lines
9.5 KiB
Python
273 lines
9.5 KiB
Python
import logging
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, 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_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.order_mail import send_order_event_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 PublicSignupInput(SQLModel):
|
|
name: str
|
|
email: str
|
|
|
|
@field_validator("email")
|
|
@classmethod
|
|
def _validate_email(cls, v: str) -> str:
|
|
return _normalize_email(v)
|
|
|
|
|
|
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 PublicRoundDetails(SQLModel):
|
|
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]
|
|
|
|
|
|
class PublicPageInfo(SQLModel):
|
|
route_name: str
|
|
meeting_info: Optional[str]
|
|
current_round: Optional[PublicRoundDetails]
|
|
|
|
|
|
def get_route_from_domain(request: Request, session: Session) -> Route:
|
|
host = request.headers.get("host", "")
|
|
domain = host.split(":")[0] # fjern evt. port
|
|
route = session.exec(select(Route).where(Route.public_domain == domain)).first()
|
|
if route is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No route configured for domain {domain!r}")
|
|
return route
|
|
|
|
|
|
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
|
|
|
|
|
|
def _signup_participant(session: Session, route_id: int, data: PublicSignupInput) -> Participant:
|
|
existing = session.exec(
|
|
select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
|
|
).first()
|
|
if existing is not None:
|
|
existing.name = data.name
|
|
existing.is_active = True # tilmelding er en eksplicit "ja tak til mails"-handling
|
|
session.add(existing)
|
|
return existing
|
|
|
|
participant = Participant(name=data.name, email=data.email, phone=None, is_active=True, 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:
|
|
existing.name = data.name
|
|
existing.is_active = True
|
|
session.add(existing)
|
|
return existing
|
|
raise
|
|
return participant
|
|
|
|
|
|
def _build_page_info(session: Session, route: Route) -> PublicPageInfo:
|
|
purchase_round = _get_open_round(session, route.id)
|
|
if purchase_round is None:
|
|
return PublicPageInfo(route_name=route.name, meeting_info=route.meeting_info, current_round=None)
|
|
|
|
offerings = session.exec(
|
|
select(WineOffering)
|
|
.where(WineOffering.purchase_round_id == purchase_round.id)
|
|
.options(selectinload(WineOffering.category))
|
|
).all()
|
|
offerings = sorted(offerings, key=lambda o: (o.category.sort_order, o.id))
|
|
|
|
return PublicPageInfo(
|
|
route_name=route.name,
|
|
meeting_info=route.meeting_info,
|
|
current_round=PublicRoundDetails(
|
|
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
|
|
],
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/current-round", response_model=PublicPageInfo)
|
|
def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo:
|
|
route = get_route_from_domain(request, session)
|
|
return _build_page_info(session, route)
|
|
|
|
|
|
@router.post("/signup", response_model=ParticipantPublic, status_code=status.HTTP_201_CREATED)
|
|
def signup(payload: PublicSignupInput, request: Request, session: SessionDep) -> Participant:
|
|
route = get_route_from_domain(request, session)
|
|
participant = _signup_participant(session, route.id, payload)
|
|
session.commit()
|
|
session.refresh(participant)
|
|
return participant
|
|
|
|
|
|
@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)
|
|
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_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)
|
|
|
|
return OrderConfirmation(
|
|
**OrderPublic.model_validate(order).model_dump(),
|
|
order_lines=[OrderLinePublic.model_validate(ol) for ol in order_lines],
|
|
)
|