vinindkoeb/app/routers/purchase_rounds.py
carsten acaa2383e9 Add admin CRUD for purchase rounds and wine offerings, with round copy
Full CRUD on PurchaseRound and WineOffering, org-scoped via two new
join-based dependencies.py helpers (get_round_in_organization,
get_wine_offering_in_organization) mirroring task 5's pattern. New
rounds always start as draft regardless of what the client posts,
avoiding a confusing creation-time constraint error.

POST /purchase-rounds/{id}/copy implements "kopiér fra forrige runde":
duplicates every round field (dates, texts) and every wine offering
(incl. category) into a fresh draft, leaving the source untouched.

PurchaseRound DELETE gets a three-tier policy based on status: draft
deletable by any admin, open never deletable, closed only by an
elevated superadmin. This is runtime-conditional on the loaded row, so
the elevation check was extracted out of get_current_active_superuser
into a standalone require_elevated_superuser(user, token) helper that
both the dependency and this handler call directly. WineOffering
delete has no such tier — an offering with real order lines is already
blocked by the existing RESTRICT FK, caught here as a 409.

Also seeds the 8 wine categories (empty table blocked any offering
creation) via a plain Alembic data migration, idempotent and
unconditional (no secret involved, unlike the task-4 superuser seed).

Verified end-to-end: dates-required 409, open round successfully
patched with dates, wine offerings created, round copied (new draft,
duplicated offerings with new ids/same category), open-round delete
403, closed-round delete 403 then 204 after /auth/elevate, wine
offering delete 204, draft round delete 204 (cascades its remaining
offering). DB left clean, participant count unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:56:38 +02:00

134 lines
5.2 KiB
Python

from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
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.purchase_round import (
PurchaseRound,
PurchaseRoundCreate,
PurchaseRoundPublic,
PurchaseRoundStatus,
PurchaseRoundUpdate,
)
from app.models.route import Route
from app.models.wine_offering import WineOffering
router = APIRouter(prefix="/purchase-rounds", tags=["purchase-rounds"])
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