vinindkoeb/app/routers/wine_offerings.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

87 lines
3.7 KiB
Python

from typing import Optional
from fastapi import APIRouter, 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_wine_offering_in_organization
from app.models.purchase_round import PurchaseRound
from app.models.route import Route
from app.models.wine_category import WineCategory
from app.models.wine_offering import WineOffering, WineOfferingCreate, WineOfferingPublic, WineOfferingUpdate
router = APIRouter(prefix="/wine-offerings", tags=["wine-offerings"])
def _get_category_or_404(session: Session, category_id: int) -> WineCategory:
category = session.get(WineCategory, category_id)
if category is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wine category not found")
return category
@router.post("", response_model=WineOfferingPublic, status_code=status.HTTP_201_CREATED)
def create_wine_offering(payload: WineOfferingCreate, session: SessionDep, current_user: CurrentUser) -> WineOffering:
get_round_in_organization(session, payload.purchase_round_id, current_user.organization_id)
_get_category_or_404(session, payload.category_id)
wine_offering = WineOffering.model_validate(payload)
session.add(wine_offering)
session.commit()
session.refresh(wine_offering)
return wine_offering
@router.get("", response_model=list[WineOfferingPublic])
def list_wine_offerings(
session: SessionDep,
current_user: CurrentUser,
purchase_round_id: Optional[int] = None,
limit: int = Query(default=100, le=500, gt=0),
offset: int = Query(default=0, ge=0),
) -> list[WineOffering]:
statement = (
select(WineOffering)
.join(PurchaseRound)
.join(Route)
.where(Route.organization_id == current_user.organization_id)
)
if purchase_round_id is not None:
statement = statement.where(WineOffering.purchase_round_id == purchase_round_id)
statement = statement.order_by(WineOffering.id).offset(offset).limit(limit)
return list(session.exec(statement).all())
@router.get("/{wine_offering_id}", response_model=WineOfferingPublic)
def get_wine_offering(wine_offering_id: int, session: SessionDep, current_user: CurrentUser) -> WineOffering:
return get_wine_offering_in_organization(session, wine_offering_id, current_user.organization_id)
@router.patch("/{wine_offering_id}", response_model=WineOfferingPublic)
def update_wine_offering(
wine_offering_id: int, payload: WineOfferingUpdate, session: SessionDep, current_user: CurrentUser
) -> WineOffering:
wine_offering = get_wine_offering_in_organization(session, wine_offering_id, current_user.organization_id)
update_data = payload.model_dump(exclude_unset=True)
if "category_id" in update_data:
_get_category_or_404(session, update_data["category_id"])
for field, value in update_data.items():
setattr(wine_offering, field, value)
session.add(wine_offering)
session.commit()
session.refresh(wine_offering)
return wine_offering
@router.delete("/{wine_offering_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_wine_offering(wine_offering_id: int, session: SessionDep, current_user: CurrentUser) -> None:
wine_offering = get_wine_offering_in_organization(session, wine_offering_id, current_user.organization_id)
session.delete(wine_offering)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This wine offering has order lines and cannot be deleted",
) from exc