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.security import OAuth2PasswordBearer
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.route import Route
from app.models.user import User
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)]
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 app.db import SessionDep
from app.routers import auth
from app.routers import auth, participants
app = FastAPI(title="Vinindkøb Admin API")
app.include_router(auth.router)
app.include_router(participants.router)
@app.get("/health")

View file

@ -1,5 +1,7 @@
from typing import TYPE_CHECKING, List, Optional
from pydantic import field_validator
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
@ -7,15 +9,25 @@ if TYPE_CHECKING:
from app.models.route import Route
def _normalize_email(value: str) -> str:
return value.strip().lower()
class ParticipantBase(SQLModel):
name: str
email: str
phone: Optional[str] = Field(default=None)
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):
__tablename__ = "participant"
__table_args__ = (UniqueConstraint("route_id", "email", name="uq_participant_route_email"),)
id: Optional[int] = Field(default=None, primary_key=True)
route_id: int = Field(foreign_key="route.id", ondelete="CASCADE")
@ -24,3 +36,25 @@ class Participant(ParticipantBase, table=True):
orders: List["Order"] = Relationship(
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 ###