From ded23efc6a7d572445a94d15b6f2017894ed065e Mon Sep 17 00:00:00 2001 From: carsten Date: Mon, 28 Sep 2026 13:05:08 +0200 Subject: [PATCH] Add admin CRUD for participants (incl. active/inactive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/dependencies.py | 21 +++++ app/main.py | 3 +- app/models/participant.py | 34 ++++++++ app/routers/participants.py | 83 +++++++++++++++++++ ...2_add_unique_constraint_on_participant_.py | 33 ++++++++ 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 app/routers/participants.py create mode 100644 migrations/versions/ddf4dfafdc62_add_unique_constraint_on_participant_.py diff --git a/app/dependencies.py b/app/dependencies.py index 0a8924e..e910e63 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -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 diff --git a/app/main.py b/app/main.py index 28d7ae6..9276502 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models/participant.py b/app/models/participant.py index 9c5a8bc..7da337c 100644 --- a/app/models/participant.py +++ b/app/models/participant.py @@ -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 diff --git a/app/routers/participants.py b/app/routers/participants.py new file mode 100644 index 0000000..5a183ec --- /dev/null +++ b/app/routers/participants.py @@ -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() diff --git a/migrations/versions/ddf4dfafdc62_add_unique_constraint_on_participant_.py b/migrations/versions/ddf4dfafdc62_add_unique_constraint_on_participant_.py new file mode 100644 index 0000000..868e4f7 --- /dev/null +++ b/migrations/versions/ddf4dfafdc62_add_unique_constraint_on_participant_.py @@ -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 ###