Server-renderet af FastAPI selv (Jinja2 + vanilla JS/CSS, intet build-step) mod det eksisterende domæneopløste JSON-API fra 8b. GET / viser bestillingssiden når en runde er åben (rundeoverskrift, intro_text, kategori-grupperet vinkatalog med live-beregnet EUR/DKK-total, bestillingsformular), ellers en tilmeldingsside. GET /tilmelding er altid tilgængelig uafhængigt af rundestatus, og GET /afhentning viser rutens meeting_info. Layoutet er modelleret efter den gamle sides bestillingsside. get_current_round's serialiseringslogik er udtrukket til en delt _build_page_info-helper, så JSON-API'et og de nye HTML-sider genbruger nøjagtig samme domæne-/rundeopslag. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
363 lines
13 KiB
Python
363 lines
13 KiB
Python
import logging
|
|
from datetime import datetime, timezone
|
|
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_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, 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
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
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],
|
|
)
|