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:
parent
5be79a7c7a
commit
ded23efc6a
5 changed files with 173 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
83
app/routers/participants.py
Normal file
83
app/routers/participants.py
Normal 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()
|
||||
|
|
@ -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 ###
|
||||
Loading…
Add table
Reference in a new issue