Add admin CRUD for participants (incl. active/inactive)

Full CRUD on Participant scoped to the caller's organization via new
get_route_in_organization/get_participant_in_organization helpers in
app/dependencies.py (shared cross-cutting spot, reused by task 6).
PATCH is_active is the primary deactivation path — replaces the old
spreadsheet-era "empty order" trick and preserves order history for
participants who leave and later return. DELETE is a separate,
superadmin-gated hard-delete for GDPR erasure requests, which cascades
to the participant's orders.

Adds UNIQUE(route_id, email) at the DB level (existing 308 participants
already conform, per task 3's dedup) plus email normalization on
create/update, so case-variant duplicates can't reappear via the API.
Every write path relies on catching the constraint's IntegrityError for
a clean 409 rather than a racy pre-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-28 13:05:08 +02:00
parent 5be79a7c7a
commit ded23efc6a
5 changed files with 173 additions and 1 deletions

View file

@ -2,9 +2,12 @@ from typing import Annotated
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
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.route import Route
from app.models.user import User from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
@ -35,3 +38,21 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
CurrentSuperuser = Annotated[User, Depends(get_current_active_superuser)] CurrentSuperuser = Annotated[User, Depends(get_current_active_superuser)]
def get_route_in_organization(session: Session, route_id: int, organization_id: int) -> Route:
route = session.get(Route, route_id)
if route is None or route.organization_id != organization_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found")
return route
def get_participant_in_organization(session: Session, participant_id: int, organization_id: int) -> Participant:
participant = session.exec(
select(Participant)
.join(Route)
.where(Participant.id == participant_id, Route.organization_id == organization_id)
).first()
if participant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Participant not found")
return participant

View file

@ -2,10 +2,11 @@ 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 from app.routers import auth, participants
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.get("/health") @app.get("/health")

View file

@ -1,5 +1,7 @@
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from pydantic import field_validator
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING: if TYPE_CHECKING:
@ -7,15 +9,25 @@ if TYPE_CHECKING:
from app.models.route import Route from app.models.route import Route
def _normalize_email(value: str) -> str:
return value.strip().lower()
class ParticipantBase(SQLModel): class ParticipantBase(SQLModel):
name: str name: str
email: str email: str
phone: Optional[str] = Field(default=None) phone: Optional[str] = Field(default=None)
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
@field_validator("email")
@classmethod
def _validate_email(cls, v: str) -> str:
return _normalize_email(v)
class Participant(ParticipantBase, table=True): class Participant(ParticipantBase, table=True):
__tablename__ = "participant" __tablename__ = "participant"
__table_args__ = (UniqueConstraint("route_id", "email", name="uq_participant_route_email"),)
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
route_id: int = Field(foreign_key="route.id", ondelete="CASCADE") route_id: int = Field(foreign_key="route.id", ondelete="CASCADE")
@ -24,3 +36,25 @@ class Participant(ParticipantBase, table=True):
orders: List["Order"] = Relationship( orders: List["Order"] = Relationship(
back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"} back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
) )
class ParticipantCreate(ParticipantBase):
route_id: int
phone: str # påkrævet ved oprettelse, selvom det er Optional i basen/DB'en
class ParticipantUpdate(SQLModel):
name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
is_active: Optional[bool] = None
@field_validator("email")
@classmethod
def _validate_email(cls, v: Optional[str]) -> Optional[str]:
return _normalize_email(v) if v is not None else v
class ParticipantPublic(ParticipantBase):
id: int
route_id: int

View file

@ -0,0 +1,83 @@
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 (
CurrentSuperuser,
CurrentUser,
get_participant_in_organization,
get_route_in_organization,
)
from app.models.participant import Participant, ParticipantCreate, ParticipantPublic, ParticipantUpdate
from app.models.route import Route
router = APIRouter(prefix="/participants", tags=["participants"])
def _commit_or_conflict(session: Session) -> None:
try:
session.commit()
except IntegrityError as exc:
session.rollback()
if "uq_participant_route_email" in str(getattr(exc, "orig", exc)):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A participant with this email already exists on this route",
) from exc
raise
@router.post("", response_model=ParticipantPublic, status_code=status.HTTP_201_CREATED)
def create_participant(payload: ParticipantCreate, session: SessionDep, current_user: CurrentUser) -> Participant:
get_route_in_organization(session, payload.route_id, current_user.organization_id)
participant = Participant.model_validate(payload)
session.add(participant)
_commit_or_conflict(session)
session.refresh(participant)
return participant
@router.get("", response_model=list[ParticipantPublic])
def list_participants(
session: SessionDep,
current_user: CurrentUser,
route_id: Optional[int] = None,
is_active: Optional[bool] = None,
limit: int = Query(default=100, le=500, gt=0),
offset: int = Query(default=0, ge=0),
) -> list[Participant]:
statement = select(Participant).join(Route).where(Route.organization_id == current_user.organization_id)
if route_id is not None:
statement = statement.where(Participant.route_id == route_id)
if is_active is not None:
statement = statement.where(Participant.is_active == is_active)
statement = statement.order_by(Participant.id).offset(offset).limit(limit)
return list(session.exec(statement).all())
@router.get("/{participant_id}", response_model=ParticipantPublic)
def get_participant(participant_id: int, session: SessionDep, current_user: CurrentUser) -> Participant:
return get_participant_in_organization(session, participant_id, current_user.organization_id)
@router.patch("/{participant_id}", response_model=ParticipantPublic)
def update_participant(
participant_id: int, payload: ParticipantUpdate, session: SessionDep, current_user: CurrentUser
) -> Participant:
participant = get_participant_in_organization(session, participant_id, current_user.organization_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(participant, field, value)
session.add(participant)
_commit_or_conflict(session)
session.refresh(participant)
return participant
@router.delete("/{participant_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_participant(participant_id: int, session: SessionDep, current_user: CurrentSuperuser) -> None:
participant = get_participant_in_organization(session, participant_id, current_user.organization_id)
session.delete(participant)
session.commit()

View file

@ -0,0 +1,33 @@
"""add unique constraint on participant route_id and email
Revision ID: ddf4dfafdc62
Revises: 237700c56d84
Create Date: 2026-09-28 13:04:02.903665
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'ddf4dfafdc62'
down_revision: Union[str, Sequence[str], None] = '237700c56d84'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint('uq_participant_route_email', 'participant', ['route_id', 'email'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uq_participant_route_email', 'participant', type_='unique')
# ### end Alembic commands ###