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.core.security import decode_access_token
from app.db import SessionDep from app.db import SessionDep
from app.models.participant import Participant from app.models.participant import Participant
from app.models.purchase_round import PurchaseRound
from app.models.route import Route from app.models.route import Route
from app.models.user import User from app.models.user import User
from app.models.wine_offering import WineOffering
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") 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)] CurrentUser = Annotated[User, Depends(get_current_user)]
def get_current_active_superuser( def require_elevated_superuser(current_user: User, token: str) -> None:
current_user: CurrentUser,
token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
if not current_user.is_superadmin: if not current_user.is_superadmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough privileges") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough privileges")
payload = decode_access_token(token) payload = decode_access_token(token)
@ -43,6 +42,13 @@ def get_current_active_superuser(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="This action requires an elevated session — call POST /auth/elevate first", 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 return current_user
@ -65,3 +71,26 @@ def get_participant_in_organization(session: Session, participant_id: int, organ
if participant is None: if participant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Participant not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Participant not found")
return participant 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 sqlalchemy import text
from app.db import SessionDep 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 = FastAPI(title="Vinindkøb Admin API")
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(participants.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") @app.get("/health")

View file

@ -61,3 +61,23 @@ class PurchaseRound(PurchaseRoundBase, table=True):
orders: List["Order"] = Relationship( orders: List["Order"] = Relationship(
back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"} 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) id: Optional[int] = Field(default=None, primary_key=True)
wine_offerings: List["WineOffering"] = Relationship(back_populates="category") 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") purchase_round: "PurchaseRound" = Relationship(back_populates="wine_offerings")
category: "WineCategory" = Relationship(back_populates="wine_offerings") category: "WineCategory" = Relationship(back_populates="wine_offerings")
order_lines: List["OrderLine"] = Relationship(back_populates="wine_offering") 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)))