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>
134 lines
5.2 KiB
Python
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
|