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:
parent
0ee6ef2e68
commit
acaa2383e9
9 changed files with 370 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
134
app/routers/purchase_rounds.py
Normal file
134
app/routers/purchase_rounds.py
Normal 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
|
||||||
13
app/routers/wine_categories.py
Normal file
13
app/routers/wine_categories.py
Normal 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())
|
||||||
87
app/routers/wine_offerings.py
Normal file
87
app/routers/wine_offerings.py
Normal 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
|
||||||
57
migrations/versions/7b5f332b7ab3_seed_wine_categories.py
Normal file
57
migrations/versions/7b5f332b7ab3_seed_wine_categories.py
Normal 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)))
|
||||||
Loading…
Add table
Reference in a new issue