from datetime import datetime, timezone from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlmodel import Session, select from app.db import SessionDep from app.dependencies import ( CurrentUser, get_round_in_organization, get_route_in_organization, oauth2_scheme, require_elevated_superuser, ) 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, PurchaseRoundCreate, PurchaseRoundPublic, PurchaseRoundStatus, PurchaseRoundUpdate, ) from app.models.route import Route from app.models.wine_offering import WineOffering from app.services.mail_rendering import format_datetime_da, render_template from app.services.postal import PostalSendError, send_mail router = APIRouter(prefix="/purchase-rounds", tags=["purchase-rounds"]) templates = Jinja2Templates(directory="app/templates") def _commit_or_conflict(session: Session) -> None: try: session.commit() except IntegrityError as exc: session.rollback() if "ck_purchase_round_dates_required_unless_draft" in str(getattr(exc, "orig", exc)): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="opens_at, order_deadline_at and pickup_at are required unless status is draft", ) from exc raise @router.post("", response_model=PurchaseRoundPublic, status_code=status.HTTP_201_CREATED) def create_purchase_round( payload: PurchaseRoundCreate, session: SessionDep, current_user: CurrentUser ) -> PurchaseRound: get_route_in_organization(session, payload.route_id, current_user.organization_id) purchase_round = PurchaseRound.model_validate(payload) purchase_round.status = PurchaseRoundStatus.DRAFT # nye runder starter altid som kladde session.add(purchase_round) _commit_or_conflict(session) session.refresh(purchase_round) return purchase_round @router.get("", response_model=list[PurchaseRoundPublic]) def list_purchase_rounds( session: SessionDep, current_user: CurrentUser, route_id: Optional[int] = None, status_: Optional[PurchaseRoundStatus] = Query(default=None, alias="status"), limit: int = Query(default=100, le=500, gt=0), offset: int = Query(default=0, ge=0), ) -> list[PurchaseRound]: statement = select(PurchaseRound).join(Route).where(Route.organization_id == current_user.organization_id) if route_id is not None: statement = statement.where(PurchaseRound.route_id == route_id) if status_ is not None: statement = statement.where(PurchaseRound.status == status_) statement = statement.order_by(PurchaseRound.id.desc()).offset(offset).limit(limit) return list(session.exec(statement).all()) @router.get("/{round_id}", response_model=PurchaseRoundPublic) def get_purchase_round(round_id: int, session: SessionDep, current_user: CurrentUser) -> PurchaseRound: return get_round_in_organization(session, round_id, current_user.organization_id) @router.patch("/{round_id}", response_model=PurchaseRoundPublic) def update_purchase_round( round_id: int, payload: PurchaseRoundUpdate, session: SessionDep, current_user: CurrentUser ) -> PurchaseRound: purchase_round = get_round_in_organization(session, round_id, current_user.organization_id) for field, value in payload.model_dump(exclude_unset=True).items(): setattr(purchase_round, field, value) session.add(purchase_round) _commit_or_conflict(session) session.refresh(purchase_round) return purchase_round @router.delete("/{round_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_purchase_round( round_id: int, session: SessionDep, current_user: CurrentUser, token: Annotated[str, Depends(oauth2_scheme)], ) -> None: purchase_round = get_round_in_organization(session, round_id, current_user.organization_id) if purchase_round.status == PurchaseRoundStatus.OPEN: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="An open purchase round cannot be deleted") if purchase_round.status == PurchaseRoundStatus.CLOSED: require_elevated_superuser(current_user, token) session.delete(purchase_round) session.commit() @router.post("/{source_round_id}/copy", response_model=PurchaseRoundPublic, status_code=status.HTTP_201_CREATED) def copy_purchase_round(source_round_id: int, session: SessionDep, current_user: CurrentUser) -> PurchaseRound: source = get_round_in_organization(session, source_round_id, current_user.organization_id) new_round = PurchaseRound( name=source.name, status=PurchaseRoundStatus.DRAFT, opens_at=source.opens_at, order_deadline_at=source.order_deadline_at, pickup_at=source.pickup_at, eur_dkk_rate=source.eur_dkk_rate, intro_text=source.intro_text, pickup_info_text=source.pickup_info_text, route_id=source.route_id, ) session.add(new_round) session.flush() # for at få new_round.id til vinudbuddene nedenfor for offering in source.wine_offerings: session.add( WineOffering( name=offering.name, price=offering.price, is_organic=offering.is_organic, category_id=offering.category_id, purchase_round_id=new_round.id, ) ) _commit_or_conflict(session) session.refresh(new_round) return new_round @router.post("/{round_id}/announce") def announce_purchase_round(round_id: int, session: SessionDep, current_user: CurrentUser) -> dict: purchase_round = get_round_in_organization(session, round_id, current_user.organization_id) if purchase_round.status != PurchaseRoundStatus.OPEN: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Round must be open before announcing") route = purchase_round.route if not route.sender_name or not route.sender_email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Route has no sender_name/sender_email configured", ) template = session.exec( select(MailTemplate).where( MailTemplate.route_id == route.id, MailTemplate.event_type == MailEventType.ROUND_ANNOUNCED ) ).first() if template is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No round_announced template configured for this route", ) participants = session.exec( select(Participant).where(Participant.route_id == route.id, Participant.is_active == True) # noqa: E712 ).all() sent = failed = 0 for participant in participants: variables = { "participant_name": participant.name, "route_name": route.name, "round_name": purchase_round.name, "opens_at": format_datetime_da(purchase_round.opens_at) if purchase_round.opens_at else "", "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 "", "intro_text": purchase_round.intro_text or "", "pickup_info_text": purchase_round.pickup_info_text or "", } 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.ROUND_ANNOUNCED, 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="round_announced", ) log.status = MailLogStatus.SENT log.postal_message_id = postal_id log.postal_token = postal_token log.sent_at = datetime.now(timezone.utc) sent += 1 except PostalSendError as exc: log.error_message = str(exc) failed += 1 session.add(log) session.commit() return {"sent": sent, "failed": failed, "total_participants": len(participants)} @router.get("/{round_id}/pickup-list", response_class=HTMLResponse) def get_pickup_list(round_id: int, request: Request, session: SessionDep, current_user: CurrentUser) -> HTMLResponse: purchase_round = get_round_in_organization(session, round_id, current_user.organization_id) orders = session.exec( select(Order) .where(Order.purchase_round_id == round_id) .options( selectinload(Order.participant), selectinload(Order.order_lines).selectinload(OrderLine.wine_offering).selectinload(WineOffering.category), ) .order_by(Order.order_number) ).all() rows = [] for order in orders: lines_sorted = sorted( order.order_lines, key=lambda ol: (ol.wine_offering.category.sort_order, ol.wine_offering.id) ) lines_by_category: dict[str, list[dict]] = {} total_bottles = 0 for ol in lines_sorted: lines_by_category.setdefault(ol.wine_offering.category.name, []).append( {"quantity": ol.quantity, "wine_name": ol.wine_offering.name} ) total_bottles += ol.quantity rows.append( { "order_number": order.order_number, "participant_name": order.participant.name, "participant_phone": order.participant.phone, "participant_email": order.participant.email, "total_bottles": total_bottles, "total_cases": f"{total_bottles / 6:.2f}", "categories": list(lines_by_category.items()), } ) return templates.TemplateResponse( request, "pickup_list.html", {"route_name": purchase_round.route.name, "round_name": purchase_round.name, "orders": rows}, )