diff --git a/app/dependencies.py b/app/dependencies.py index c8bdfc4..308b563 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -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 diff --git a/app/main.py b/app/main.py index 9276502..bffab66 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models/purchase_round.py b/app/models/purchase_round.py index 2289f61..c5c0563 100644 --- a/app/models/purchase_round.py +++ b/app/models/purchase_round.py @@ -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 diff --git a/app/models/wine_category.py b/app/models/wine_category.py index 12c4053..e27f096 100644 --- a/app/models/wine_category.py +++ b/app/models/wine_category.py @@ -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 diff --git a/app/models/wine_offering.py b/app/models/wine_offering.py index aaa673d..c984d6a 100644 --- a/app/models/wine_offering.py +++ b/app/models/wine_offering.py @@ -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 diff --git a/app/routers/purchase_rounds.py b/app/routers/purchase_rounds.py new file mode 100644 index 0000000..ce18167 --- /dev/null +++ b/app/routers/purchase_rounds.py @@ -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 diff --git a/app/routers/wine_categories.py b/app/routers/wine_categories.py new file mode 100644 index 0000000..48adf8b --- /dev/null +++ b/app/routers/wine_categories.py @@ -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()) diff --git a/app/routers/wine_offerings.py b/app/routers/wine_offerings.py new file mode 100644 index 0000000..6b1074a --- /dev/null +++ b/app/routers/wine_offerings.py @@ -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 diff --git a/migrations/versions/7b5f332b7ab3_seed_wine_categories.py b/migrations/versions/7b5f332b7ab3_seed_wine_categories.py new file mode 100644 index 0000000..dde66f2 --- /dev/null +++ b/migrations/versions/7b5f332b7ab3_seed_wine_categories.py @@ -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)))