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], )