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>
This commit is contained in:
Carsten Gram 2026-09-28 18:56:38 +02:00
parent 0ee6ef2e68
commit acaa2383e9
9 changed files with 370 additions and 5 deletions

View file

@ -7,8 +7,10 @@ from sqlmodel import Session, select
from app.core.security import decode_access_token
from app.db import SessionDep
from app.models.participant import Participant
from app.models.purchase_round import PurchaseRound
from app.models.route import Route
from app.models.user import User
from app.models.wine_offering import WineOffering
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
@ -31,10 +33,7 @@ def get_current_user(session: SessionDep, token: Annotated[str, Depends(oauth2_s
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_current_active_superuser(
current_user: CurrentUser,
token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
def require_elevated_superuser(current_user: User, token: str) -> None:
if not current_user.is_superadmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough privileges")
payload = decode_access_token(token)
@ -43,6 +42,13 @@ def get_current_active_superuser(
status_code=status.HTTP_403_FORBIDDEN,
detail="This action requires an elevated session — call POST /auth/elevate first",
)
def get_current_active_superuser(
current_user: CurrentUser,
token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
require_elevated_superuser(current_user, token)
return current_user
@ -65,3 +71,26 @@ def get_participant_in_organization(session: Session, participant_id: int, organ
if participant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Participant not found")
return participant
def get_round_in_organization(session: Session, round_id: int, organization_id: int) -> PurchaseRound:
purchase_round = session.exec(
select(PurchaseRound)
.join(Route)
.where(PurchaseRound.id == round_id, Route.organization_id == organization_id)
).first()
if purchase_round is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Purchase round not found")
return purchase_round
def get_wine_offering_in_organization(session: Session, wine_offering_id: int, organization_id: int) -> WineOffering:
wine_offering = session.exec(
select(WineOffering)
.join(PurchaseRound)
.join(Route)
.where(WineOffering.id == wine_offering_id, Route.organization_id == organization_id)
).first()
if wine_offering is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wine offering not found")
return wine_offering

View file

@ -2,11 +2,14 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.db import SessionDep
from app.routers import auth, participants
from app.routers import auth, participants, purchase_rounds, wine_categories, wine_offerings
app = FastAPI(title="Vinindkøb Admin API")
app.include_router(auth.router)
app.include_router(participants.router)
app.include_router(purchase_rounds.router)
app.include_router(wine_offerings.router)
app.include_router(wine_categories.router)
@app.get("/health")

View file

@ -61,3 +61,23 @@ class PurchaseRound(PurchaseRoundBase, table=True):
orders: List["Order"] = Relationship(
back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
class PurchaseRoundCreate(PurchaseRoundBase):
route_id: int
class PurchaseRoundUpdate(SQLModel):
name: Optional[str] = None
status: Optional[PurchaseRoundStatus] = None
opens_at: Optional[datetime] = None
order_deadline_at: Optional[datetime] = None
pickup_at: Optional[datetime] = None
eur_dkk_rate: Optional[Decimal] = None
intro_text: Optional[str] = None
pickup_info_text: Optional[str] = None
class PurchaseRoundPublic(PurchaseRoundBase):
id: int
route_id: int

View file

@ -17,3 +17,7 @@ class WineCategory(WineCategoryBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
wine_offerings: List["WineOffering"] = Relationship(back_populates="category")
class WineCategoryPublic(WineCategoryBase):
id: int

View file

@ -25,3 +25,21 @@ class WineOffering(WineOfferingBase, table=True):
purchase_round: "PurchaseRound" = Relationship(back_populates="wine_offerings")
category: "WineCategory" = Relationship(back_populates="wine_offerings")
order_lines: List["OrderLine"] = Relationship(back_populates="wine_offering")
class WineOfferingCreate(WineOfferingBase):
purchase_round_id: int
category_id: int
class WineOfferingUpdate(SQLModel):
name: Optional[str] = None
price: Optional[Decimal] = None
is_organic: Optional[bool] = None
category_id: Optional[int] = None
class WineOfferingPublic(WineOfferingBase):
id: int
purchase_round_id: int
category_id: int

View file

@ -0,0 +1,134 @@
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

View file

@ -0,0 +1,13 @@
from fastapi import APIRouter
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import CurrentUser
from app.models.wine_category import WineCategory, WineCategoryPublic
router = APIRouter(prefix="/wine-categories", tags=["wine-categories"])
@router.get("", response_model=list[WineCategoryPublic])
def list_wine_categories(session: SessionDep, current_user: CurrentUser) -> list[WineCategory]:
return list(session.exec(select(WineCategory).order_by(WineCategory.sort_order)).all())

View file

@ -0,0 +1,87 @@
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

View file

@ -0,0 +1,57 @@
"""seed wine categories
Revision ID: 7b5f332b7ab3
Revises: ddf4dfafdc62
Create Date: 2026-09-28 18:54:58.568518
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '7b5f332b7ab3'
down_revision: Union[str, Sequence[str], None] = 'ddf4dfafdc62'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
CATEGORY_NAMES = [
"TRADITION",
"SELECTION",
"LES IMPERTINENTS",
"GRANDS CRUS",
"VENDANGES TARDIVES",
"CREMANT",
"MAGNUM",
"SANS ALCOOL",
]
wine_category_table = sa.table(
"wine_category",
sa.column("id", sa.Integer),
sa.column("name", sa.String),
sa.column("sort_order", sa.Integer),
)
def upgrade() -> None:
"""Upgrade schema."""
bind = op.get_bind()
existing = bind.execute(sa.select(sa.func.count()).select_from(wine_category_table)).scalar_one()
if existing > 0:
print(f"wine_category already has {existing} row(s) — skipping seed.")
return
bind.execute(
sa.insert(wine_category_table),
[{"name": name, "sort_order": i} for i, name in enumerate(CATEGORY_NAMES)],
)
print(f"Seeded {len(CATEGORY_NAMES)} wine categories.")
def downgrade() -> None:
"""Downgrade schema."""
bind = op.get_bind()
bind.execute(sa.delete(wine_category_table).where(wine_category_table.c.name.in_(CATEGORY_NAMES)))