diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index eb444b3d3b..f3f56d69eb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -44,6 +44,7 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
+ --name postgres
ports:
# Maps tcp port 5432 on service container to the host
- 5432:5432
diff --git a/app/core/schools/schemas_schools.py b/app/core/schools/schemas_schools.py
index e6b7d6e704..5cf2125bb5 100644
--- a/app/core/schools/schemas_schools.py
+++ b/app/core/schools/schemas_schools.py
@@ -1,6 +1,6 @@
from uuid import UUID
-from pydantic import BaseModel, field_validator
+from pydantic import BaseModel, ConfigDict, field_validator
from app.utils import validators
@@ -17,6 +17,8 @@ class CoreSchoolBase(BaseModel):
class CoreSchool(CoreSchoolBase):
id: UUID
+ model_config = ConfigDict(from_attributes=True)
+
class CoreSchoolUpdate(BaseModel):
"""Schema for school update"""
diff --git a/app/core/users/factory_users.py b/app/core/users/factory_users.py
index 0f51db4e04..efa8931fb4 100644
--- a/app/core/users/factory_users.py
+++ b/app/core/users/factory_users.py
@@ -122,11 +122,11 @@ async def create_core_users(cls, db: AsyncSession):
name=user_info.name,
email=user_info.email,
floor=None,
- phone=None,
+ phone=user_info.phone,
promo=None,
school_id=SchoolType.centrale_lyon.value,
account_type=groups_type.AccountType.student,
- birthday=None,
+ birthday=user_info.birthday,
created_on=datetime.now(tz=UTC),
)
await cruds_users.create_user(db=db, user=user)
diff --git a/app/core/utils/config.py b/app/core/utils/config.py
index 0368f44292..a36d204f63 100644
--- a/app/core/utils/config.py
+++ b/app/core/utils/config.py
@@ -1,5 +1,6 @@
import pathlib
import tomllib
+from datetime import date
from functools import cached_property
from re import Pattern
from typing import Any, ClassVar
@@ -98,6 +99,8 @@ class UserDemoFactoryConfig(BaseModel):
email: str
password: str | None # If None, the password will be generated randomly
groups: list[str] = [] # Groups id to which the user will be added
+ phone: str | None = None
+ birthday: date | None = None
class Settings(BaseSettings):
diff --git a/app/modules/raid/coredata_raid.py b/app/modules/raid/coredata_raid.py
index 5082a7dc20..19e043fa03 100644
--- a/app/modules/raid/coredata_raid.py
+++ b/app/modules/raid/coredata_raid.py
@@ -18,12 +18,6 @@ class RaidInformation(core_data.BaseCoreData):
raid_information_id: str | None = None
-class RaidDriveFolders(core_data.BaseCoreData):
- parent_folder_id: str | None = None
- registering_folder_id: str | None = None
- security_folder_id: str | None = None
-
-
class RaidPrice(core_data.BaseCoreData):
student_price: int | None = None
partner_price: int | None = None
diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py
index 7a464befd4..8db6ddef8a 100644
--- a/app/modules/raid/cruds_raid.py
+++ b/app/modules/raid/cruds_raid.py
@@ -1,145 +1,237 @@
-from collections.abc import Sequence
+import uuid
from datetime import UTC, datetime
+from uuid import UUID
-from sqlalchemy import delete, or_, select, update
+from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.modules.raid import models_raid, schemas_raid
-from app.modules.raid.raid_type import Difficulty, DocumentValidation
+from app.modules.raid.raid_type import (
+ Difficulty,
+ DocumentValidation,
+ RaidRegistrationStatus,
+)
async def create_participant(
- participant: models_raid.RaidParticipant,
+ participant: schemas_raid.RaidParticipantCreate,
db: AsyncSession,
-) -> models_raid.RaidParticipant:
- db.add(participant)
+) -> None:
+ db.add(
+ models_raid.RaidParticipant(
+ user_id=participant.user_id,
+ edition_id=participant.edition_id,
+ status=participant.status,
+ address=participant.address,
+ bike_size=participant.bike_size,
+ t_shirt_size=participant.t_shirt_size,
+ situation=participant.situation,
+ other_school=participant.other_school,
+ company=participant.company,
+ diet=participant.diet,
+ id_card_id=participant.id_card_id,
+ medical_certificate_id=participant.medical_certificate_id,
+ security_file_id=participant.security_file_id,
+ student_card_id=participant.student_card_id,
+ raid_rules_id=participant.raid_rules_id,
+ parent_authorization_id=participant.parent_authorization_id,
+ attestation_on_honour=participant.attestation_on_honour,
+ payment=participant.payment,
+ t_shirt_payment=participant.t_shirt_payment,
+ is_minor=participant.is_minor,
+ ),
+ )
await db.flush()
- return participant
async def get_all_participants(
+ edition_id: UUID,
db: AsyncSession,
-) -> Sequence[models_raid.RaidParticipant]:
- participants = await db.execute(
- select(models_raid.RaidParticipant).options(
- # Since there is nested classes in the RaidParticipant model, we need to load all the related data
- selectinload("*"),
- ),
- )
- return participants.scalars().all()
+ status: RaidRegistrationStatus | None = None,
+) -> list[schemas_raid.RaidParticipant]:
+ stmt = (
+ select(models_raid.RaidParticipant)
+ .where(models_raid.RaidParticipant.edition_id == edition_id)
+ .options(selectinload("*"))
+ )
+ if status is not None:
+ stmt = stmt.where(models_raid.RaidParticipant.status == status)
+ participants = await db.execute(stmt)
+ return [
+ schemas_raid.RaidParticipant.model_validate(p)
+ for p in participants.scalars().all()
+ ]
async def update_participant(
- participant_id: str,
- participant: schemas_raid.RaidParticipantUpdate,
- is_minor: bool | None,
+ user_id: str,
+ edition_id: UUID,
+ values: schemas_raid.RaidParticipantUpdate,
db: AsyncSession,
) -> None:
- query = (
+ values_dict = values.model_dump(exclude_none=True)
+ if not values_dict:
+ return
+ await db.execute(
update(models_raid.RaidParticipant)
- .where(models_raid.RaidParticipant.id == participant_id)
- .values(**participant.model_dump(exclude_none=True))
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
+ .values(**values_dict),
)
-
- if is_minor:
- query = query.values(is_minor=is_minor)
- await db.execute(query)
await db.flush()
async def update_participant_minority(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
is_minor: bool,
db: AsyncSession,
) -> None:
await db.execute(
update(models_raid.RaidParticipant)
- .where(models_raid.RaidParticipant.id == participant_id)
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
.values(is_minor=is_minor),
)
await db.flush()
+async def update_participant_status(
+ user_id: str,
+ edition_id: UUID,
+ status: RaidRegistrationStatus,
+ db: AsyncSession,
+) -> None:
+ await db.execute(
+ update(models_raid.RaidParticipant)
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
+ .values(status=status),
+ )
+ await db.flush()
+
+
async def is_user_a_participant(
user_id: str,
+ edition_id: UUID,
db: AsyncSession,
) -> bool:
- participant = await db.execute(
- select(models_raid.RaidParticipant).where(
- models_raid.RaidParticipant.id == user_id,
+ result = await db.execute(
+ select(models_raid.RaidParticipant.user_id).where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
),
)
- return bool(participant.scalars().first())
+ return result.first() is not None
async def get_team_by_participant_id(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
db: AsyncSession,
-) -> models_raid.RaidTeam | None:
+) -> schemas_raid.RaidTeam | None:
team = await db.execute(
select(models_raid.RaidTeam)
.where(
+ models_raid.RaidTeam.edition_id == edition_id,
or_(
- models_raid.RaidTeam.captain_id == participant_id,
- models_raid.RaidTeam.second_id == participant_id,
+ models_raid.RaidTeam.captain_id == user_id,
+ models_raid.RaidTeam.second_id == user_id,
),
)
- .options(
- # Since there is nested classes in the RaidTeam model, we need to load all the related data
- selectinload("*"),
- ),
+ .options(selectinload("*")),
)
- return team.scalars().first()
+ model = team.scalars().first()
+ return schemas_raid.RaidTeam.model_validate(model) if model else None
async def get_all_teams(
+ edition_id: UUID,
db: AsyncSession,
-) -> Sequence[models_raid.RaidTeam]:
+) -> list[schemas_raid.RaidTeam]:
teams = await db.execute(
- select(models_raid.RaidTeam).options(
- # Since there is nested classes in the RaidTeam model, we need to load all the related data
- selectinload("*"),
- ),
+ select(models_raid.RaidTeam)
+ .where(models_raid.RaidTeam.edition_id == edition_id)
+ .options(selectinload("*")),
)
- return teams.scalars().all()
+ return [schemas_raid.RaidTeam.model_validate(t) for t in teams.scalars().all()]
async def get_all_validated_teams(
- db: AsyncSession,
-) -> Sequence[models_raid.RaidTeam]:
- teams = await db.execute(
- select(models_raid.RaidTeam).options(
- # Since there is nested classes in the RaidTeam model, we need to load all the related data
- selectinload("*"),
- ),
+ edition_id: UUID,
+ db: AsyncSession,
+) -> list[schemas_raid.RaidTeam]:
+ """Validated = captain AND second both have status=validated."""
+ # We use raw table aliases instead of ORM relationships because:
+ # 1. The composite FK (user_id + edition_id) on RaidParticipant means the
+ # `captain` and `second` relationships on RaidTeam are not simple
+ # single-column joins; SQLAlchemy can't easily express "join on both
+ # captain_id+edition_id AND second_id+edition_id simultaneously" via
+ # the relationship API without loading the full object graph.
+ # 2. A raw join lets us filter on participant.status in the same query
+ # without an extra round-trip or in-Python filtering.
+ Captain = models_raid.RaidParticipant.__table__.alias("captain_p")
+ Second = models_raid.RaidParticipant.__table__.alias("second_p")
+ stmt = (
+ select(models_raid.RaidTeam)
+ .where(models_raid.RaidTeam.edition_id == edition_id)
+ .join(
+ Captain,
+ (Captain.c.user_id == models_raid.RaidTeam.captain_id)
+ & (Captain.c.edition_id == models_raid.RaidTeam.edition_id),
+ )
+ .join(
+ Second,
+ (Second.c.user_id == models_raid.RaidTeam.second_id)
+ & (Second.c.edition_id == models_raid.RaidTeam.edition_id),
+ )
+ .where(
+ Captain.c.status == RaidRegistrationStatus.validated,
+ Second.c.status == RaidRegistrationStatus.validated,
+ )
+ .options(selectinload("*"))
)
- teams_found = teams.scalars().all()
- # We can not use a where clause because the validation_progress is a Python property
- # and is not usable in a SQL query
- return list(filter(lambda team: team.validation_progress == 100, teams_found))
+ teams = await db.execute(stmt)
+ return [schemas_raid.RaidTeam.model_validate(t) for t in teams.scalars().all()]
async def get_team_by_id(
team_id: str,
db: AsyncSession,
-) -> models_raid.RaidTeam | None:
+) -> schemas_raid.RaidTeam | None:
team = await db.execute(
select(models_raid.RaidTeam)
.where(models_raid.RaidTeam.id == team_id)
- .options(
- # Since there is nested classes in the RaidTeam model, we need to load all the related data
- selectinload("*"),
- ),
+ .options(selectinload("*")),
)
- return team.scalars().first()
+ model = team.scalars().first()
+ return schemas_raid.RaidTeam.model_validate(model) if model else None
async def create_team(
- team: models_raid.RaidTeam,
+ team: schemas_raid.RaidTeamCreate,
db: AsyncSession,
) -> None:
- db.add(team)
+ db.add(
+ models_raid.RaidTeam(
+ id=team.id,
+ edition_id=team.edition_id,
+ name=team.name,
+ difficulty=team.difficulty,
+ captain_id=team.captain_id,
+ second_id=team.second_id,
+ number=team.number,
+ meeting_place=team.meeting_place,
+ file_id=team.file_id,
+ ),
+ )
await db.flush()
@@ -148,10 +240,13 @@ async def update_team(
team: schemas_raid.RaidTeamUpdate,
db: AsyncSession,
) -> None:
+ values = team.model_dump(exclude_none=True)
+ if not values:
+ return
await db.execute(
update(models_raid.RaidTeam)
.where(models_raid.RaidTeam.id == team_id)
- .values(**team.model_dump(exclude_none=True)),
+ .values(**values),
)
await db.flush()
@@ -183,21 +278,28 @@ async def update_team_second_id(
async def delete_participant(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
db: AsyncSession,
) -> None:
await db.execute(
delete(models_raid.RaidParticipant).where(
- models_raid.RaidParticipant.id == participant_id,
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
),
)
await db.flush()
async def delete_all_participant(
+ edition_id: UUID,
db: AsyncSession,
) -> None:
- await db.execute(delete(models_raid.RaidParticipant))
+ await db.execute(
+ delete(models_raid.RaidParticipant).where(
+ models_raid.RaidParticipant.edition_id == edition_id,
+ ),
+ )
await db.flush()
@@ -214,9 +316,14 @@ async def delete_team_invite_tokens(
async def delete_all_invite_tokens(
+ edition_id: UUID,
db: AsyncSession,
) -> None:
- await db.execute(delete(models_raid.InviteToken))
+ await db.execute(
+ delete(models_raid.InviteToken).where(
+ models_raid.InviteToken.edition_id == edition_id,
+ ),
+ )
await db.flush()
@@ -231,19 +338,43 @@ async def delete_team(
async def delete_all_teams(
+ edition_id: UUID,
db: AsyncSession,
) -> None:
- await db.execute(delete(models_raid.RaidTeam))
+ await db.execute(
+ delete(models_raid.RaidTeam).where(
+ models_raid.RaidTeam.edition_id == edition_id,
+ ),
+ )
await db.flush()
async def add_security_file(
- security_file: models_raid.SecurityFile,
+ security_file: schemas_raid.SecurityFile,
+ edition_id: UUID,
db: AsyncSession,
-) -> models_raid.SecurityFile:
- db.add(security_file)
+) -> None:
+ db.add(
+ models_raid.SecurityFile(
+ id=security_file.id,
+ edition_id=edition_id,
+ allergy=security_file.allergy,
+ asthma=security_file.asthma,
+ intensive_care_unit=security_file.intensive_care_unit,
+ intensive_care_unit_when=security_file.intensive_care_unit_when,
+ ongoing_treatment=security_file.ongoing_treatment,
+ sicknesses=security_file.sicknesses,
+ hospitalization=security_file.hospitalization,
+ surgical_operation=security_file.surgical_operation,
+ trauma=security_file.trauma,
+ family=security_file.family,
+ emergency_person_firstname=security_file.emergency_person_firstname,
+ emergency_person_name=security_file.emergency_person_name,
+ emergency_person_phone=security_file.emergency_person_phone,
+ file_id=security_file.file_id,
+ ),
+ )
await db.flush()
- return security_file
async def delete_security_file(
@@ -285,36 +416,53 @@ async def update_security_file_id(
async def assign_security_file(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
security_file_id: str,
db: AsyncSession,
) -> None:
await db.execute(
update(models_raid.RaidParticipant)
- .where(models_raid.RaidParticipant.id == participant_id)
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
.values(security_file_id=security_file_id),
)
await db.flush()
async def create_document(
- document: models_raid.Document,
+ document: schemas_raid.Document,
+ edition_id: UUID,
db: AsyncSession,
-) -> models_raid.Document:
- db.add(document)
+) -> None:
+ db.add(
+ models_raid.Document(
+ id=document.id,
+ edition_id=edition_id,
+ name=document.name,
+ uploaded_at=document.uploaded_at,
+ type=document.type,
+ validation=document.validation,
+ ),
+ )
await db.flush()
- return document
async def assign_document(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
document_id: str | None,
document_key: str,
db: AsyncSession,
) -> None:
await db.execute(
update(models_raid.RaidParticipant)
- .where(models_raid.RaidParticipant.id == participant_id)
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
.values({document_key: document_id}),
)
await db.flush()
@@ -336,37 +484,33 @@ async def update_document_validation(
async def get_document_by_id(
document_id: str,
db: AsyncSession,
-) -> models_raid.Document | None:
+) -> schemas_raid.Document | None:
document = await db.execute(
select(models_raid.Document).where(models_raid.Document.id == document_id),
)
- return document.scalars().first()
+ model = document.scalars().first()
+ return schemas_raid.Document.model_validate(model) if model else None
async def get_user_by_document_id(
document_id: str,
db: AsyncSession,
-) -> models_raid.RaidParticipant | None:
+) -> schemas_raid.RaidParticipant | None:
document = await db.execute(
- select(models_raid.RaidParticipant).where(
+ select(models_raid.RaidParticipant)
+ .where(
or_(
models_raid.RaidParticipant.id_card_id == document_id,
models_raid.RaidParticipant.medical_certificate_id == document_id,
models_raid.RaidParticipant.student_card_id == document_id,
models_raid.RaidParticipant.raid_rules_id == document_id,
+ models_raid.RaidParticipant.parent_authorization_id == document_id,
),
- ),
+ )
+ .options(selectinload("*")),
)
- return document.scalars().first()
-
-
-async def upload_document(
- document: models_raid.Document,
- db: AsyncSession,
-) -> models_raid.Document:
- db.add(document)
- await db.flush()
- return document
+ model = document.scalars().first()
+ return schemas_raid.RaidParticipant.model_validate(model) if model else None
async def update_document(
@@ -385,115 +529,142 @@ async def update_document(
async def mark_document_as_newly_updated(
document_id: str,
db: AsyncSession,
-):
+) -> None:
await db.execute(
update(models_raid.Document)
.where(models_raid.Document.id == document_id)
.values(uploaded_at=datetime.now(tz=UTC).date(), validation="pending"),
)
-
await db.flush()
async def confirm_payment(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
db: AsyncSession,
) -> None:
await db.execute(
update(models_raid.RaidParticipant)
- .where(models_raid.RaidParticipant.id == participant_id)
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
.values(payment=True),
)
await db.flush()
async def confirm_t_shirt_payment(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
db: AsyncSession,
) -> None:
await db.execute(
update(models_raid.RaidParticipant)
- .where(models_raid.RaidParticipant.id == participant_id)
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
.values(t_shirt_payment=True),
)
await db.flush()
async def validate_attestation_on_honour(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
db: AsyncSession,
) -> None:
await db.execute(
update(models_raid.RaidParticipant)
- .where(models_raid.RaidParticipant.id == participant_id)
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
.values(attestation_on_honour=True),
)
await db.flush()
-async def get_participant_by_id(
- participant_id: str,
+async def get_participant_by_user_id(
+ user_id: str,
+ edition_id: UUID,
db: AsyncSession,
-) -> models_raid.RaidParticipant | None:
+) -> schemas_raid.RaidParticipant | None:
participant = await db.execute(
select(models_raid.RaidParticipant)
- .where(models_raid.RaidParticipant.id == participant_id)
- .options(
- selectinload("*"),
- ),
+ .where(
+ models_raid.RaidParticipant.user_id == user_id,
+ models_raid.RaidParticipant.edition_id == edition_id,
+ )
+ .options(selectinload("*")),
)
- return participant.scalars().first()
+ model = participant.scalars().first()
+ return schemas_raid.RaidParticipant.model_validate(model) if model else None
async def get_number_of_teams(
+ edition_id: UUID,
db: AsyncSession,
) -> int:
- result = await db.execute(select(models_raid.RaidTeam))
- return len(result.scalars().all())
+ result = await db.execute(
+ select(func.count())
+ .select_from(models_raid.RaidTeam)
+ .where(models_raid.RaidTeam.edition_id == edition_id),
+ )
+ return result.scalar() or 0
async def get_security_file_by_security_id(
security_id: str,
db: AsyncSession,
-) -> models_raid.SecurityFile | None:
+) -> schemas_raid.SecurityFile | None:
security_file = await db.execute(
select(models_raid.SecurityFile).where(
models_raid.SecurityFile.id == security_id,
),
)
- return security_file.scalars().first()
+ model = security_file.scalars().first()
+ return schemas_raid.SecurityFile.model_validate(model) if model else None
async def create_invite_token(
- invite: models_raid.InviteToken,
+ invite: schemas_raid.InviteToken,
db: AsyncSession,
-) -> models_raid.InviteToken:
- db.add(invite)
+) -> None:
+ db.add(
+ models_raid.InviteToken(
+ id=invite.id,
+ edition_id=invite.edition_id,
+ team_id=invite.team_id,
+ token=invite.token,
+ ),
+ )
await db.flush()
- return invite
-
async def get_invite_token_by_team_id(
team_id: str,
db: AsyncSession,
-) -> models_raid.InviteToken | None:
+) -> schemas_raid.InviteToken | None:
invite = await db.execute(
select(models_raid.InviteToken).where(
models_raid.InviteToken.team_id == team_id,
),
)
- return invite.scalars().first()
+ model = invite.scalars().first()
+ return schemas_raid.InviteToken.model_validate(model) if model else None
async def get_invite_token_by_token(
token: str,
db: AsyncSession,
-) -> models_raid.InviteToken | None:
+) -> schemas_raid.InviteToken | None:
invite = await db.execute(
select(models_raid.InviteToken).where(models_raid.InviteToken.token == token),
)
- return invite.scalars().first()
+ model = invite.scalars().first()
+ return schemas_raid.InviteToken.model_validate(model) if model else None
async def delete_invite_token(
@@ -506,35 +677,6 @@ async def delete_invite_token(
await db.flush()
-async def are_user_in_the_same_team(
- participant_id_1: str,
- participant_id_2: str,
- db: AsyncSession,
-) -> bool:
- return (
- await get_team_if_users_in_the_same_team(
- participant_id_1=participant_id_1,
- participant_id_2=participant_id_2,
- db=db,
- )
- is not None
- )
-
-
-async def get_team_if_users_in_the_same_team(
- participant_id_1: str,
- participant_id_2: str,
- db: AsyncSession,
-) -> models_raid.RaidTeam | None:
- team_1 = await get_team_by_participant_id(participant_id_1, db)
- team_2 = await get_team_by_participant_id(participant_id_2, db)
- if team_1 is None or team_2 is None:
- return None
- if team_1.id != team_2.id:
- return None
- return team_1
-
-
async def update_team_file_id(
team_id: str,
file_id: str,
@@ -548,49 +690,322 @@ async def update_team_file_id(
await db.flush()
-async def get_number_of_team_by_difficulty(
+async def get_max_team_number_by_difficulty(
difficulty: Difficulty,
+ edition_id: UUID,
db: AsyncSession,
) -> int:
- result = await db.execute(
- select(models_raid.RaidTeam).where(
+ """Returns the highest team number among validated teams for a difficulty.
+
+ Validated = both captain and second have status=validated.
+ """
+ # Same rationale as in get_all_validated_teams: the composite FK on
+ # RaidParticipant makes the ORM relationships awkward for a simultaneous
+ # join on both captain+edition_id and second+edition_id while filtering
+ # on participant.status in the same query.
+ Captain = models_raid.RaidParticipant.__table__.alias("captain_p")
+ Second = models_raid.RaidParticipant.__table__.alias("second_p")
+ stmt = (
+ select(func.max(models_raid.RaidTeam.number))
+ .where(
+ models_raid.RaidTeam.edition_id == edition_id,
models_raid.RaidTeam.difficulty == difficulty,
- ),
- )
- teams_found = result.scalars().all()
- # We can not use a where clause because the validation_progress is a Python property
- # and is not usable in a SQL query
- team_numbers = [
- team.number if team.number is not None and team.number >= 0 else 0
- for team in filter(
- lambda team: (
- team.validation_progress == 100
- and team.number is not None
- and team.number >= 0
- ),
- teams_found,
)
- ]
- return max(team_numbers) if team_numbers else 0
+ .join(
+ Captain,
+ (Captain.c.user_id == models_raid.RaidTeam.captain_id)
+ & (Captain.c.edition_id == models_raid.RaidTeam.edition_id),
+ )
+ .join(
+ Second,
+ (Second.c.user_id == models_raid.RaidTeam.second_id)
+ & (Second.c.edition_id == models_raid.RaidTeam.edition_id),
+ )
+ .where(
+ Captain.c.status == RaidRegistrationStatus.validated,
+ Second.c.status == RaidRegistrationStatus.validated,
+ )
+ )
+ result = await db.execute(stmt)
+ return result.scalar() or 0
async def create_participant_checkout(
- checkout: models_raid.RaidParticipantCheckout,
+ checkout: schemas_raid.RaidParticipantCheckout,
db: AsyncSession,
-) -> models_raid.RaidParticipantCheckout:
- db.add(checkout)
+) -> None:
+ db.add(
+ models_raid.RaidParticipantCheckout(
+ id=str(uuid.uuid4()),
+ participant_user_id=checkout.participant_user_id,
+ edition_id=checkout.edition_id,
+ checkout_id=checkout.checkout_id,
+ ),
+ )
await db.flush()
- return checkout
async def get_participant_checkout_by_checkout_id(
- # TODO: use UUID
checkout_id: str,
db: AsyncSession,
-) -> models_raid.RaidParticipantCheckout | None:
+) -> schemas_raid.RaidParticipantCheckout | None:
checkout = await db.execute(
select(models_raid.RaidParticipantCheckout).where(
models_raid.RaidParticipantCheckout.checkout_id == checkout_id,
),
)
- return checkout.scalars().first()
+ model = checkout.scalars().first()
+ return schemas_raid.RaidParticipantCheckout.model_validate(model) if model else None
+
+
+# --- Edition CRUDs ------------------------------------------------------
+
+
+async def get_all_editions(
+ db: AsyncSession,
+) -> list[schemas_raid.RaidEdition]:
+ result = await db.execute(select(models_raid.RaidEdition))
+ return [
+ schemas_raid.RaidEdition(
+ id=e.id,
+ year=e.year,
+ name=e.name,
+ start_date=e.start_date,
+ end_date=e.end_date,
+ registering_end_date=e.registering_end_date,
+ active=e.active,
+ inscription_enabled=e.inscription_enabled,
+ )
+ for e in result.scalars().all()
+ ]
+
+
+async def get_edition_by_id(
+ edition_id: UUID,
+ db: AsyncSession,
+) -> schemas_raid.RaidEdition | None:
+ result = await db.execute(
+ select(models_raid.RaidEdition).where(
+ models_raid.RaidEdition.id == edition_id,
+ ),
+ )
+ model = result.scalars().first()
+ if model is None:
+ return None
+ return schemas_raid.RaidEdition(
+ id=model.id,
+ year=model.year,
+ name=model.name,
+ start_date=model.start_date,
+ end_date=model.end_date,
+ registering_end_date=model.registering_end_date,
+ active=model.active,
+ inscription_enabled=model.inscription_enabled,
+ )
+
+
+async def get_active_edition(
+ db: AsyncSession,
+) -> schemas_raid.RaidEdition | None:
+ result = await db.execute(
+ select(models_raid.RaidEdition).where(
+ models_raid.RaidEdition.active == True, # noqa: E712
+ ),
+ )
+ model = result.scalars().first()
+ if model is None:
+ return None
+ return schemas_raid.RaidEdition(
+ id=model.id,
+ year=model.year,
+ name=model.name,
+ start_date=model.start_date,
+ end_date=model.end_date,
+ registering_end_date=model.registering_end_date,
+ active=model.active,
+ inscription_enabled=model.inscription_enabled,
+ )
+
+
+async def create_edition(
+ edition: schemas_raid.RaidEdition,
+ db: AsyncSession,
+) -> None:
+ db.add(
+ models_raid.RaidEdition(
+ id=edition.id,
+ year=edition.year,
+ name=edition.name,
+ start_date=edition.start_date,
+ end_date=edition.end_date,
+ registering_end_date=edition.registering_end_date,
+ active=edition.active,
+ inscription_enabled=edition.inscription_enabled,
+ ),
+ )
+ await db.flush()
+
+
+async def update_edition(
+ edition_id: UUID,
+ edit: schemas_raid.RaidEditionEdit,
+ db: AsyncSession,
+) -> None:
+ values = edit.model_dump(exclude_unset=True)
+ if not values:
+ return
+ await db.execute(
+ update(models_raid.RaidEdition)
+ .where(models_raid.RaidEdition.id == edition_id)
+ .values(**values),
+ )
+ await db.flush()
+
+
+async def delete_edition(
+ edition_id: UUID,
+ db: AsyncSession,
+) -> None:
+ await db.execute(
+ delete(models_raid.RaidEdition).where(
+ models_raid.RaidEdition.id == edition_id,
+ ),
+ )
+ await db.flush()
+
+
+async def deactivate_all_editions(
+ db: AsyncSession,
+) -> None:
+ await db.execute(
+ update(models_raid.RaidEdition).values(active=False),
+ )
+ await db.flush()
+
+
+# --- Volunteer CRUDs ---------------------------------------------------
+
+
+async def create_volunteer(
+ volunteer: schemas_raid.RaidVolunteerCreate,
+ db: AsyncSession,
+) -> None:
+ db.add(
+ models_raid.RaidVolunteer(
+ user_id=volunteer.user_id,
+ edition_id=volunteer.edition_id,
+ created_at=volunteer.created_at,
+ validated=volunteer.validated,
+ cancelled=volunteer.cancelled,
+ t_shirt_size=volunteer.t_shirt_size,
+ diet=volunteer.diet,
+ allergy=volunteer.allergy,
+ emergency_person_name=volunteer.emergency_person_name,
+ emergency_person_phone=volunteer.emergency_person_phone,
+ has_car=volunteer.has_car,
+ car_seats=volunteer.car_seats,
+ is_special_driver=volunteer.is_special_driver,
+ is_utility_vehicle_driver=volunteer.is_utility_vehicle_driver,
+ is_parcours_helper=volunteer.is_parcours_helper,
+ ),
+ )
+ await db.flush()
+
+
+async def get_volunteer_by_user_id(
+ user_id: str,
+ edition_id: UUID,
+ db: AsyncSession,
+) -> schemas_raid.RaidVolunteer | None:
+ result = await db.execute(
+ select(models_raid.RaidVolunteer).where(
+ models_raid.RaidVolunteer.user_id == user_id,
+ models_raid.RaidVolunteer.edition_id == edition_id,
+ ),
+ )
+ model = result.scalars().first()
+ return schemas_raid.RaidVolunteer.model_validate(model) if model else None
+
+
+async def get_all_volunteers_by_edition(
+ edition_id: UUID,
+ db: AsyncSession,
+ validated: bool | None = None,
+) -> list[schemas_raid.RaidVolunteer]:
+ stmt = select(models_raid.RaidVolunteer).where(
+ models_raid.RaidVolunteer.edition_id == edition_id,
+ )
+ if validated is not None:
+ stmt = stmt.where(models_raid.RaidVolunteer.validated == validated)
+ result = await db.execute(stmt)
+ return [
+ schemas_raid.RaidVolunteer.model_validate(v) for v in result.scalars().all()
+ ]
+
+
+async def update_volunteer(
+ user_id: str,
+ edition_id: UUID,
+ values: schemas_raid.RaidVolunteerEdit,
+ db: AsyncSession,
+) -> None:
+ values_dict = values.model_dump(exclude_none=True)
+ if not values_dict:
+ return
+ await db.execute(
+ update(models_raid.RaidVolunteer)
+ .where(
+ models_raid.RaidVolunteer.user_id == user_id,
+ models_raid.RaidVolunteer.edition_id == edition_id,
+ )
+ .values(**values_dict),
+ )
+ await db.flush()
+
+
+async def update_volunteer_validation(
+ user_id: str,
+ edition_id: UUID,
+ validated: bool,
+ db: AsyncSession,
+) -> None:
+ await db.execute(
+ update(models_raid.RaidVolunteer)
+ .where(
+ models_raid.RaidVolunteer.user_id == user_id,
+ models_raid.RaidVolunteer.edition_id == edition_id,
+ )
+ .values(validated=validated),
+ )
+ await db.flush()
+
+
+async def update_volunteer_cancellation(
+ user_id: str,
+ edition_id: UUID,
+ cancelled: bool,
+ db: AsyncSession,
+) -> None:
+ await db.execute(
+ update(models_raid.RaidVolunteer)
+ .where(
+ models_raid.RaidVolunteer.user_id == user_id,
+ models_raid.RaidVolunteer.edition_id == edition_id,
+ )
+ .values(cancelled=cancelled),
+ )
+ await db.flush()
+
+
+async def delete_volunteer(
+ user_id: str,
+ edition_id: UUID,
+ db: AsyncSession,
+) -> None:
+ await db.execute(
+ delete(models_raid.RaidVolunteer).where(
+ models_raid.RaidVolunteer.user_id == user_id,
+ models_raid.RaidVolunteer.edition_id == edition_id,
+ ),
+ )
+ await db.flush()
diff --git a/app/modules/raid/dependencies_raid.py b/app/modules/raid/dependencies_raid.py
new file mode 100644
index 0000000000..f3db544126
--- /dev/null
+++ b/app/modules/raid/dependencies_raid.py
@@ -0,0 +1,78 @@
+"""FastAPI dependencies for the raid module.
+
+Provides `get_current_raid_edition` (the active edition) plus helpers used
+across endpoints to enforce the disjoint participant / volunteer track and
+fetch scoped entities with a 404 fallback.
+"""
+
+from uuid import UUID
+
+from fastapi import Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.dependencies import get_db
+from app.modules.raid import cruds_raid, schemas_raid
+from app.modules.raid.raid_type import RaidRegistrationStatus
+
+
+async def get_current_raid_edition(
+ db: AsyncSession = Depends(get_db),
+) -> schemas_raid.RaidEdition:
+ edition = await cruds_raid.get_active_edition(db)
+ if not edition:
+ raise HTTPException(status_code=404, detail="No active raid edition")
+ return edition
+
+
+async def get_participant_or_404(
+ user_id: str,
+ edition_id: UUID,
+ db: AsyncSession = Depends(get_db),
+) -> schemas_raid.RaidParticipant:
+ participant = await cruds_raid.get_participant_by_user_id(user_id, edition_id, db)
+ if participant is None:
+ raise HTTPException(status_code=404, detail="Participant not found")
+ return participant
+
+
+async def get_volunteer_or_404(
+ user_id: str,
+ edition_id: UUID,
+ db: AsyncSession = Depends(get_db),
+) -> schemas_raid.RaidVolunteer:
+ volunteer = await cruds_raid.get_volunteer_by_user_id(user_id, edition_id, db)
+ if volunteer is None:
+ raise HTTPException(status_code=404, detail="Volunteer not found")
+ return volunteer
+
+
+async def ensure_user_is_not_participant_in_edition(
+ user_id: str,
+ edition_id: UUID,
+ db: AsyncSession,
+) -> None:
+ # A cancelled participant has given up their slot — they can re-register
+ # on the other track (e.g. switch from participant to volunteer).
+ participant = await cruds_raid.get_participant_by_user_id(user_id, edition_id, db)
+ if (
+ participant is not None
+ and participant.status != RaidRegistrationStatus.cancelled
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail="User is already a participant in this edition",
+ )
+
+
+async def ensure_user_is_not_volunteer_in_edition(
+ user_id: str,
+ edition_id: UUID,
+ db: AsyncSession,
+) -> None:
+ # Same rationale as above: a cancelled volunteer can register as participant.
+ volunteer = await cruds_raid.get_volunteer_by_user_id(user_id, edition_id, db)
+ if volunteer is not None and not volunteer.cancelled:
+ raise HTTPException(
+ status_code=400,
+ detail="User is already a volunteer in this edition",
+ )
diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py
index f223877bb8..5a50b21fa2 100644
--- a/app/modules/raid/endpoints_raid.py
+++ b/app/modules/raid/endpoints_raid.py
@@ -1,6 +1,6 @@
import logging
import uuid
-from datetime import UTC, date, datetime
+from datetime import UTC, datetime
from anyio import Path
from fastapi import Depends, File, HTTPException, UploadFile
@@ -17,20 +17,37 @@
get_payment_tool,
is_user_allowed_to,
)
-from app.modules.raid import coredata_raid, cruds_raid, models_raid, schemas_raid
-from app.modules.raid.raid_type import DocumentType, DocumentValidation, Size
+from app.modules.raid import coredata_raid, cruds_raid, schemas_raid
+from app.modules.raid.dependencies_raid import (
+ ensure_user_is_not_participant_in_edition,
+ ensure_user_is_not_volunteer_in_edition,
+ get_current_raid_edition,
+ get_participant_or_404,
+ get_volunteer_or_404,
+)
+from app.modules.raid.factory_raid import RaidFactory
+from app.modules.raid.raid_type import (
+ DocumentType,
+ DocumentValidation,
+ RaidRegistrationStatus,
+ Size,
+)
from app.modules.raid.utils.utils_raid import (
calculate_raid_payment,
get_all_security_files_zip,
get_all_team_files_zip,
- get_participant,
validate_payment,
- will_participant_be_minor_on,
+ will_birthday_be_minor_on,
+)
+from app.modules.raid.utils.validation_checker import (
+ check_participant_validation_consistency,
+ check_volunteer_validation_consistency,
)
from app.types.content_type import ContentType
from app.types.module import Module
from app.utils.tools import (
delete_all_folder_from_data,
+ delete_file_from_data,
get_core_data,
get_file_from_data,
get_random_string,
@@ -52,27 +69,140 @@ class RaidPermissions(ModulePermissions):
tag="Raid",
payment_callback=validate_payment,
default_allowed_account_types=list(AccountType),
- factory=None,
+ factory=RaidFactory(),
permissions=RaidPermissions,
)
+# ---------------------------------------------------------------------------
+# Editions
+# ---------------------------------------------------------------------------
+
+
+@module.router.get(
+ "/raid/editions",
+ response_model=list[schemas_raid.RaidEdition],
+ status_code=200,
+)
+async def list_editions(
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.manage_raid]),
+ ),
+):
+ return await cruds_raid.get_all_editions(db)
+
+
+@module.router.get(
+ "/raid/editions/active",
+ response_model=schemas_raid.RaidEdition,
+ status_code=200,
+)
+async def get_active_edition(
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ return edition
+
+
+@module.router.post(
+ "/raid/editions",
+ response_model=schemas_raid.RaidEdition,
+ status_code=201,
+)
+async def create_edition(
+ edition: schemas_raid.RaidEditionBase,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.manage_raid]),
+ ),
+):
+ if edition.active:
+ await cruds_raid.deactivate_all_editions(db)
+ edition_id = uuid.uuid4()
+ edition_schema = schemas_raid.RaidEdition(
+ id=edition_id,
+ name=edition.name,
+ year=edition.year,
+ start_date=edition.start_date,
+ end_date=edition.end_date,
+ registering_end_date=edition.registering_end_date,
+ active=edition.active,
+ inscription_enabled=edition.inscription_enabled,
+ )
+ await cruds_raid.create_edition(edition_schema, db)
+ return await cruds_raid.get_edition_by_id(edition_id, db)
+
+
+@module.router.patch(
+ "/raid/editions/{edition_id}",
+ status_code=204,
+)
+async def update_edition(
+ edition_id: uuid.UUID,
+ edit: schemas_raid.RaidEditionEdit,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.manage_raid]),
+ ),
+):
+ existing = await cruds_raid.get_edition_by_id(edition_id, db)
+ if not existing:
+ raise HTTPException(status_code=404, detail="Edition not found")
+ if edit.active is True and not existing.active:
+ await cruds_raid.deactivate_all_editions(db)
+ await cruds_raid.update_edition(edition_id, edit, db)
+
+
+@module.router.delete(
+ "/raid/editions/{edition_id}",
+ status_code=204,
+)
+async def delete_edition(
+ edition_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.manage_raid]),
+ ),
+):
+ # Cancelled records don't block deletion — the user already gave up their slot.
+ participants = await cruds_raid.get_all_participants(edition_id, db)
+ active_participants = [
+ p for p in participants if p.status != RaidRegistrationStatus.cancelled
+ ]
+ if active_participants:
+ raise HTTPException(
+ status_code=400,
+ detail="Edition has participants; cannot delete",
+ )
+ volunteers = await cruds_raid.get_all_volunteers_by_edition(edition_id, db)
+ active_volunteers = [v for v in volunteers if not v.cancelled]
+ if active_volunteers:
+ raise HTTPException(
+ status_code=400,
+ detail="Edition has volunteers; cannot delete",
+ )
+ await cruds_raid.delete_edition(edition_id, db)
+
+
+# ---------------------------------------------------------------------------
+# Participants
+# ---------------------------------------------------------------------------
+
+
@module.router.get(
- "/raid/participants/{participant_id}",
+ "/raid/participants/{user_id}",
response_model=schemas_raid.RaidParticipant,
status_code=200,
)
async def get_participant_by_id(
- participant_id: str,
+ user_id: str,
db: AsyncSession = Depends(get_db),
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Get a participant by id
- """
- if participant_id != user.id and not await has_user_permission(
+ if user_id != user.id and not await has_user_permission(
user,
RaidPermissions.manage_raid,
db,
@@ -81,8 +211,7 @@ async def get_participant_by_id(
status_code=403,
detail="You can not get data of another user",
)
-
- return await get_participant(participant_id, db)
+ return await get_participant_or_404(user_id, edition.id, db)
@module.router.post(
@@ -91,138 +220,218 @@ async def get_participant_by_id(
status_code=201,
)
async def create_participant(
- participant: schemas_raid.RaidParticipantBase,
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Create a participant
- """
- # If the user is already a participant, return an error
- if await cruds_raid.is_user_a_participant(user.id, db):
+ """Create a participant. Identity (name/firstname/email/birthday/phone)
+ is read from the CoreUser and must already be set there."""
+ if await cruds_raid.is_user_a_participant(user.id, edition.id, db):
raise HTTPException(status_code=403, detail="You are already a participant.")
+ await ensure_user_is_not_volunteer_in_edition(user.id, edition.id, db)
- raid_information = await get_core_data(coredata_raid.RaidInformation, db)
- # If the start_date is not set, we will use January the first of next year to determine if participants
- # are minors. We can safely assume that the RAID will occurre before Jan 1 of next year
+ if not user.birthday or not user.phone:
+ raise HTTPException(
+ status_code=400,
+ detail="Your user profile is missing birthday or phone; please update it first.",
+ )
- is_minor = will_participant_be_minor_on(
- participant=participant,
+ raid_information = await get_core_data(coredata_raid.RaidInformation, db)
+ is_minor = will_birthday_be_minor_on(
+ birthday=user.birthday,
raid_start_date=raid_information.raid_start_date,
)
- db_participant = models_raid.RaidParticipant(
- **participant.__dict__,
- id=user.id,
+ participant_create = schemas_raid.RaidParticipantCreate(
+ user_id=user.id,
+ edition_id=edition.id,
+ status=RaidRegistrationStatus.draft,
is_minor=is_minor,
)
- return await cruds_raid.create_participant(db_participant, db)
+ await cruds_raid.create_participant(participant_create, db)
+ return await get_participant_or_404(user.id, edition.id, db)
@module.router.patch(
- "/raid/participants/{participant_id}",
+ "/raid/participants/{user_id}",
status_code=204,
)
async def update_participant(
- participant_id: str,
- participant: schemas_raid.RaidParticipantUpdate,
+ user_id: str,
+ participant_update: schemas_raid.RaidParticipantUpdate,
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Update a participant
- """
- # If the user is not a participant, return an error
- if not await cruds_raid.is_user_a_participant(participant_id, db):
- raise HTTPException(status_code=403, detail="You are not a participant.")
+ saved_participant = await get_participant_or_404(user_id, edition.id, db)
- # If the user is not the participant, return an error
- if not await cruds_raid.are_user_in_the_same_team(user.id, participant_id, db):
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ if user.id != user_id and not is_admin:
raise HTTPException(status_code=403, detail="You are not the participant.")
+ if not is_admin and saved_participant.status != RaidRegistrationStatus.draft:
+ raise HTTPException(
+ status_code=400,
+ detail="Participant is not in draft state; reopen first",
+ )
- raid_information = await get_core_data(coredata_raid.RaidInformation, db)
- raid_start_date = raid_information.raid_start_date or date(
- year=datetime.now(UTC).year + 1,
- month=1,
- day=1,
- )
+ if (
+ saved_participant.t_shirt_payment
+ and participant_update.t_shirt_size == Size.None_
+ ):
+ participant_update.t_shirt_size = saved_participant.t_shirt_size
+
+ for attr, label in (
+ ("id_card_id", "id_card"),
+ ("medical_certificate_id", "medical_certificate"),
+ ("student_card_id", "student_card"),
+ ("raid_rules_id", "raid_rules"),
+ ("parent_authorization_id", "parent_authorization"),
+ ):
+ doc_id = getattr(participant_update, attr)
+ if doc_id and not await cruds_raid.get_document_by_id(doc_id, db):
+ raise HTTPException(
+ status_code=404,
+ detail=f"Document {label} not found.",
+ )
- # We only want to change the is_minor value if the birthday is changed
- is_minor = None
- if participant.birthday:
- is_minor = will_participant_be_minor_on(participant, raid_start_date)
+ if participant_update.security_file_id:
+ if not await cruds_raid.get_security_file_by_security_id(
+ participant_update.security_file_id,
+ db,
+ ):
+ raise HTTPException(status_code=404, detail="Security_file not found.")
- saved_participant = await get_participant(participant_id, db)
+ await cruds_raid.update_participant(user_id, edition.id, participant_update, db)
- # If the t_shirt_payment is set, we cannot remove the t_shirt_size
- if saved_participant.t_shirt_payment and participant.t_shirt_size == Size.None_:
- participant.t_shirt_size = saved_participant.t_shirt_size
- if participant.id_card_id:
- id_card_document = await cruds_raid.get_document_by_id(
- participant.id_card_id,
- db=db,
+@module.router.post(
+ "/raid/participants/{user_id}/submit",
+ status_code=204,
+)
+async def submit_participant(
+ user_id: str,
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.access_raid]),
+ ),
+ db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ if user_id != user.id:
+ raise HTTPException(status_code=403, detail="You are not the participant.")
+ participant = await get_participant_or_404(user_id, edition.id, db)
+ if participant.status != RaidRegistrationStatus.draft:
+ raise HTTPException(status_code=400, detail="Participant is not a draft")
+ # Light gate: attestation + security file + docs accepted + payment + team.
+ # Team/payment may legitimately be not done yet at submit time, so only
+ # check attestation + security file + presence of docs here.
+ if not participant.attestation_on_honour:
+ raise HTTPException(
+ status_code=400,
+ detail="Attestation on honour not signed",
)
- if not id_card_document:
- raise HTTPException(status_code=404, detail="Document id_card not found.")
+ if participant.security_file_id is None:
+ raise HTTPException(status_code=400, detail="Security file missing")
+ if not (
+ participant.id_card_id
+ and participant.medical_certificate_id
+ and participant.raid_rules_id
+ ):
+ raise HTTPException(status_code=400, detail="Required documents missing")
+ await cruds_raid.update_participant_status(
+ user_id,
+ edition.id,
+ RaidRegistrationStatus.submitted,
+ db,
+ )
- if participant.medical_certificate_id:
- medical_certificate_document = await cruds_raid.get_document_by_id(
- participant.medical_certificate_id,
- db=db,
- )
- if not medical_certificate_document:
- raise HTTPException(
- status_code=404,
- detail="Document medical_certificate not found.",
- )
- if participant.student_card_id:
- student_card_document = await cruds_raid.get_document_by_id(
- participant.student_card_id,
- db=db,
- )
- if not student_card_document:
- raise HTTPException(
- status_code=404,
- detail="Document student_card not found.",
- )
- if participant.raid_rules_id:
- raid_rules_document = await cruds_raid.get_document_by_id(
- participant.raid_rules_id,
- db=db,
- )
- if not raid_rules_document:
- raise HTTPException(
- status_code=404,
- detail="Document raid_rules not found.",
- )
- if participant.parent_authorization_id:
- parent_authorization_document = await cruds_raid.get_document_by_id(
- participant.parent_authorization_id,
- db=db,
+
+@module.router.post(
+ "/raid/participants/{user_id}/reopen",
+ status_code=204,
+)
+async def reopen_participant(
+ user_id: str,
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.access_raid]),
+ ),
+ db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ if user_id != user.id and not is_admin:
+ raise HTTPException(status_code=403, detail="You are not the participant.")
+ participant = await get_participant_or_404(user_id, edition.id, db)
+ if participant.status == RaidRegistrationStatus.validated and not is_admin:
+ raise HTTPException(
+ status_code=403,
+ detail="Cannot reopen a validated participant",
)
- if not parent_authorization_document:
- raise HTTPException(
- status_code=404,
- detail="Document parent_authorization not found.",
- )
+ await cruds_raid.update_participant_status(
+ user_id,
+ edition.id,
+ RaidRegistrationStatus.draft,
+ db,
+ )
- if participant.security_file_id:
- security_file = await cruds_raid.get_security_file_by_security_id(
- participant.security_file_id,
- db=db,
+
+@module.router.patch(
+ "/raid/participants/{user_id}/validate",
+ status_code=204,
+)
+async def validate_participant(
+ user_id: str,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.manage_raid]),
+ ),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ participant = await get_participant_or_404(user_id, edition.id, db)
+ await check_participant_validation_consistency(participant, edition.id, db)
+ await cruds_raid.update_participant_status(
+ user_id,
+ edition.id,
+ RaidRegistrationStatus.validated,
+ db,
+ )
+
+
+@module.router.patch(
+ "/raid/participants/{user_id}/cancel",
+ status_code=204,
+)
+async def cancel_participant(
+ user_id: str,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.access_raid]),
+ ),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ participant = await get_participant_or_404(user_id, edition.id, db)
+ if user_id != user.id and not is_admin:
+ raise HTTPException(status_code=403, detail="You are not the participant.")
+ if participant.status == RaidRegistrationStatus.validated and not is_admin:
+ raise HTTPException(
+ status_code=403,
+ detail="Only admins can cancel a validated participant",
)
- if not security_file:
- raise HTTPException(
- status_code=404,
- detail="Security_file not found.",
- )
+ await cruds_raid.update_participant_status(
+ user_id,
+ edition.id,
+ RaidRegistrationStatus.cancelled,
+ db,
+ )
+
- await cruds_raid.update_participant(participant_id, participant, is_minor, db)
+# ---------------------------------------------------------------------------
+# Teams
+# ---------------------------------------------------------------------------
@module.router.post(
@@ -236,55 +445,47 @@ async def create_team(
is_user_allowed_to([RaidPermissions.access_raid]),
),
db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Create a team
- """
- # If the user is not a participant, return an error
- if not await cruds_raid.is_user_a_participant(user.id, db):
+ if not await cruds_raid.is_user_a_participant(user.id, edition.id, db):
raise HTTPException(status_code=403, detail="You are not a participant.")
-
- # If the user already has a team, return an error
- if await cruds_raid.get_team_by_participant_id(user.id, db):
+ if await cruds_raid.get_team_by_participant_id(user.id, edition.id, db):
raise HTTPException(status_code=403, detail="You already have a team.")
- db_team = models_raid.RaidTeam(
- id=str(uuid.uuid4()),
+ team_id = str(uuid.uuid4())
+ team_create = schemas_raid.RaidTeamCreate(
+ id=team_id,
+ edition_id=edition.id,
name=team.name,
number=None,
captain_id=user.id,
second_id=None,
difficulty=None,
)
- await cruds_raid.create_team(db_team, db)
- # We need to get the team from the db to have access to relationships
- return await cruds_raid.get_team_by_id(team_id=db_team.id, db=db)
+ await cruds_raid.create_team(team_create, db)
+ return await cruds_raid.get_team_by_id(team_id=team_id, db=db)
@module.router.get(
- "/raid/participants/{participant_id}/team",
+ "/raid/participants/{user_id}/team",
response_model=schemas_raid.RaidTeam,
status_code=200,
)
async def get_team_by_participant_id(
- participant_id: str,
+ user_id: str,
db: AsyncSession = Depends(get_db),
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Get a team by participant id
- """
- if user.id != participant_id:
+ if user.id != user_id:
raise HTTPException(status_code=403, detail="You are not the participant.")
-
- # If the user is not a participant, return an error
- if not await cruds_raid.is_user_a_participant(participant_id, db):
- raise HTTPException(status_code=403, detail="You are not a participant.")
-
- participant_team = await cruds_raid.get_team_by_participant_id(participant_id, db)
- # If the user does not have a team, return an error
+ participant_team = await cruds_raid.get_team_by_participant_id(
+ user_id,
+ edition.id,
+ db,
+ )
if not participant_team:
raise HTTPException(status_code=404, detail="You do not have a team.")
return participant_team
@@ -300,11 +501,9 @@ async def get_all_teams(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Get all teams
- """
- return await cruds_raid.get_all_teams(db)
+ return await cruds_raid.get_all_teams(edition.id, db)
@module.router.get(
@@ -319,10 +518,10 @@ async def get_team_by_id(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
):
- """
- Get a team by id
- """
- return await cruds_raid.get_team_by_id(team_id, db)
+ team = await cruds_raid.get_team_by_id(team_id, db)
+ if not team:
+ raise HTTPException(status_code=404, detail="Team not found.")
+ return team
@module.router.patch(
@@ -336,14 +535,13 @@ async def update_team(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Update a team
- """
- existing_team = await cruds_raid.get_team_by_participant_id(user.id, db)
- if existing_team is None:
+ existing_team = await cruds_raid.get_team_by_participant_id(user.id, edition.id, db)
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ if existing_team is None and not is_admin:
raise HTTPException(status_code=404, detail="Team not found.")
- if existing_team.id != team_id:
+ if existing_team is not None and existing_team.id != team_id and not is_admin:
raise HTTPException(status_code=403, detail="You can only edit your own team.")
await cruds_raid.update_team(team_id, team, db)
@@ -359,9 +557,6 @@ async def delete_team(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
):
- """
- Delete a team
- """
team = await cruds_raid.get_team_by_id(team_id, db)
if not team:
raise HTTPException(status_code=400, detail="This team does not exists")
@@ -378,20 +573,18 @@ async def delete_all_teams(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Delete all teams
- """
- # Delete team invite tokens
- await cruds_raid.delete_all_invite_tokens(db)
-
- # Delete all teams from the database
- await cruds_raid.delete_all_teams(db)
+ """Wipe all teams and participants of the active edition."""
+ await cruds_raid.delete_all_invite_tokens(edition.id, db)
+ await cruds_raid.delete_all_teams(edition.id, db)
+ await cruds_raid.delete_all_participant(edition.id, db)
+ await delete_all_folder_from_data("raid")
- # Delete all participants from the database
- await cruds_raid.delete_all_participant(db)
- await delete_all_folder_from_data("raid")
+# ---------------------------------------------------------------------------
+# Documents
+# ---------------------------------------------------------------------------
@module.router.post(
@@ -406,48 +599,51 @@ async def upload_document(
is_user_allowed_to([RaidPermissions.access_raid]),
),
db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Upload a document
- """
document_id = str(uuid.uuid4())
await save_file_as_data(
upload_file=file,
directory="raid",
filename=document_id,
- max_file_size=50 * 1024 * 1024, # TODO : Change this value
+ max_file_size=50 * 1024 * 1024,
accepted_content_types=[
ContentType.jpg,
ContentType.png,
ContentType.webp,
ContentType.pdf,
- ], # TODO : Change this value
+ ],
)
- model_document = models_raid.Document(
- uploaded_at=datetime.now(UTC).date(),
- validation=DocumentValidation.pending,
+ document_schema = schemas_raid.Document(
id=document_id,
name=file.filename or document_id,
+ uploaded_at=datetime.now(UTC).date(),
type=document_type,
+ validation=DocumentValidation.pending,
)
-
- await cruds_raid.create_document(model_document, db)
- document_key = ""
- match document_type:
- case DocumentType.idCard:
- document_key = "id_card_id"
- case DocumentType.medicalCertificate:
- document_key = "medical_certificate_id"
- case DocumentType.studentCard:
- document_key = "student_card_id"
- case DocumentType.raidRules:
- document_key = "raid_rules_id"
- case DocumentType.parentAuthorization:
- document_key = "parent_authorization_id"
- await cruds_raid.assign_document(user.id, document_id, document_key, db)
-
+ try:
+ await cruds_raid.create_document(document_schema, edition.id, db)
+
+ document_key = {
+ DocumentType.idCard: "id_card_id",
+ DocumentType.medicalCertificate: "medical_certificate_id",
+ DocumentType.studentCard: "student_card_id",
+ DocumentType.raidRules: "raid_rules_id",
+ DocumentType.parentAuthorization: "parent_authorization_id",
+ }[document_type]
+ await cruds_raid.assign_document(
+ user.id,
+ edition.id,
+ document_id,
+ document_key,
+ db,
+ )
+ except Exception:
+ # Rollback: delete the uploaded file if DB operations fail
+ await delete_file_from_data(directory="raid", filename=document_id)
+ raise
return schemas_raid.DocumentCreation(id=document_id)
@@ -462,19 +658,14 @@ async def read_document(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Read a document
- """
-
document = await cruds_raid.get_document_by_id(document_id, db)
-
if not document:
raise HTTPException(status_code=404, detail="Document not found.")
participant = await cruds_raid.get_user_by_document_id(document_id, db)
if not participant:
- # The document can be a global document
information = await get_core_data(coredata_raid.RaidInformation, db)
if document_id in {information.raid_rules_id, information.raid_information_id}:
return await get_file_from_data(
@@ -487,22 +678,29 @@ async def read_document(
detail="Participant owning the document not found.",
)
- if not await cruds_raid.are_user_in_the_same_team(
- user.id,
- participant.id,
- db,
- ) and not await has_user_permission(
- user,
- RaidPermissions.manage_raid,
- db,
- ):
- raise HTTPException(
- status_code=403,
- detail="The owner of this document is not a member of your team.",
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ if not is_admin:
+ # Self or teammate can read
+ user_team = await cruds_raid.get_team_by_participant_id(
+ user.id,
+ edition.id,
+ db,
)
+ owner_team = await cruds_raid.get_team_by_participant_id(
+ participant.user_id,
+ edition.id,
+ db,
+ )
+ if user.id != participant.user_id and (
+ user_team is None or owner_team is None or user_team.id != owner_team.id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="The owner of this document is not a member of your team.",
+ )
return await get_file_from_data(
- default_asset="assets/images/default_advert.png", # TODO: get a default document
+ default_asset="assets/pdf/default_PDF.pdf",
directory="raid",
filename=str(document_id),
)
@@ -520,12 +718,14 @@ async def validate_document(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
):
- """
- Validate a document
- """
await cruds_raid.update_document_validation(document_id, validation, db)
+# ---------------------------------------------------------------------------
+# Security file
+# ---------------------------------------------------------------------------
+
+
@module.router.post(
"/raid/security_file/",
response_model=schemas_raid.SecurityFile,
@@ -538,33 +738,41 @@ async def set_security_file(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Confirm security file
- """
- team = await cruds_raid.get_team_if_users_in_the_same_team(
- user.id,
- participant_id,
- db,
- )
- if team is None:
- raise HTTPException(status_code=403, detail="You are not the participant.")
+ """Submit or replace the security file of a participant (self or teammate)."""
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ if user.id != participant_id and not is_admin:
+ user_team = await cruds_raid.get_team_by_participant_id(
+ user.id,
+ edition.id,
+ db,
+ )
+ target_team = await cruds_raid.get_team_by_participant_id(
+ participant_id,
+ edition.id,
+ db,
+ )
+ if user_team is None or target_team is None or user_team.id != target_team.id:
+ raise HTTPException(status_code=403, detail="You are not the participant.")
- participant = await get_participant(participant_id, db)
- if participant is None:
- raise HTTPException(status_code=403, detail="The participant does not exist")
+ participant = await get_participant_or_404(participant_id, edition.id, db)
if participant.security_file_id:
- # The participant already has a security file
- # We want to delete it to replace it by the new one
await cruds_raid.update_security_file(
security_file_id=participant.security_file_id,
security_file=security_file,
db=db,
)
+ return await cruds_raid.get_security_file_by_security_id(
+ participant.security_file_id,
+ db,
+ )
- model_security_file = models_raid.SecurityFile(
- id=str(uuid.uuid4()),
+ new_security_file_id = str(uuid.uuid4())
+ security_file_schema = schemas_raid.SecurityFile(
+ id=new_security_file_id,
+ validation=DocumentValidation.pending,
allergy=security_file.allergy,
asthma=security_file.asthma,
intensive_care_unit=security_file.intensive_care_unit,
@@ -580,70 +788,81 @@ async def set_security_file(
emergency_person_phone=security_file.emergency_person_phone,
file_id=security_file.file_id,
)
- created_security_file = await cruds_raid.add_security_file(model_security_file, db)
- await cruds_raid.assign_security_file(participant_id, created_security_file.id, db)
+ await cruds_raid.add_security_file(security_file_schema, edition.id, db)
+ await cruds_raid.assign_security_file(
+ participant_id,
+ edition.id,
+ new_security_file_id,
+ db,
+ )
+ return await cruds_raid.get_security_file_by_security_id(
+ new_security_file_id,
+ db,
+ )
+
- return created_security_file
+# ---------------------------------------------------------------------------
+# Manual payment + attestation
+# ---------------------------------------------------------------------------
@module.router.post(
- "/raid/participant/{participant_id}/payment",
+ "/raid/participant/{user_id}/payment",
status_code=204,
)
async def confirm_payment(
- participant_id: str,
+ user_id: str,
db: AsyncSession = Depends(get_db),
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Confirm payment manually
- """
- await cruds_raid.confirm_payment(participant_id, db)
+ await cruds_raid.confirm_payment(user_id, edition.id, db)
@module.router.post(
- "/raid/participant/{participant_id}/t_shirt_payment",
+ "/raid/participant/{user_id}/t_shirt_payment",
status_code=204,
)
async def confirm_t_shirt_payment(
- participant_id: str,
+ user_id: str,
db: AsyncSession = Depends(get_db),
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Confirm T shirt payment
- """
- participant = await cruds_raid.get_participant_by_id(participant_id, db)
+ participant = await cruds_raid.get_participant_by_user_id(user_id, edition.id, db)
if (
not participant
or not participant.t_shirt_size
or participant.t_shirt_size == Size.None_
):
raise HTTPException(status_code=400, detail="T shirt size not set.")
- await cruds_raid.confirm_t_shirt_payment(participant_id, db)
+ await cruds_raid.confirm_t_shirt_payment(user_id, edition.id, db)
@module.router.post(
- "/raid/participant/{participant_id}/honour",
+ "/raid/participant/{user_id}/honour",
status_code=204,
)
async def validate_attestation_on_honour(
- participant_id: str,
+ user_id: str,
db: AsyncSession = Depends(get_db),
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Validate attestation on honour
- """
- if participant_id != user.id:
+ if user_id != user.id:
raise HTTPException(status_code=403, detail="You are not the participant")
- await cruds_raid.validate_attestation_on_honour(participant_id, db)
+ await cruds_raid.validate_attestation_on_honour(user_id, edition.id, db)
+
+
+# ---------------------------------------------------------------------------
+# Invite + join + kick + merge
+# ---------------------------------------------------------------------------
@module.router.post(
@@ -657,30 +876,26 @@ async def create_invite_token(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Create an invite token
- """
- team = await cruds_raid.get_team_by_participant_id(user.id, db)
-
+ team = await cruds_raid.get_team_by_participant_id(user.id, edition.id, db)
if not team:
raise HTTPException(status_code=404, detail="Team not found.")
-
if team.id != team_id:
raise HTTPException(status_code=403, detail="You are not in the team.")
- existing_invite_token = await cruds_raid.get_invite_token_by_team_id(team_id, db)
-
- if existing_invite_token:
- return existing_invite_token
+ existing = await cruds_raid.get_invite_token_by_team_id(team_id, db)
+ if existing:
+ return existing
- invite_token = models_raid.InviteToken(
+ invite_token = schemas_raid.InviteToken(
id=str(uuid.uuid4()),
+ edition_id=edition.id,
team_id=team_id,
token=get_random_string(length=10),
)
-
- return await cruds_raid.create_invite_token(invite_token, db)
+ await cruds_raid.create_invite_token(invite_token, db)
+ return await cruds_raid.get_invite_token_by_team_id(team_id, db)
@module.router.post(
@@ -693,33 +908,25 @@ async def join_team(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.access_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Join a team
- """
invite_token = await cruds_raid.get_invite_token_by_token(token, db)
-
if not invite_token:
raise HTTPException(status_code=404, detail="Invite token not found.")
+ if invite_token.edition_id != edition.id:
+ raise HTTPException(status_code=400, detail="Invite for a different edition")
- user_team = await cruds_raid.get_team_by_participant_id(user.id, db)
-
- # An user that is in a team without a second participant will quit its teams to joint the other
- # If there are already two participants in the user's team, we want to raise an error
+ user_team = await cruds_raid.get_team_by_participant_id(user.id, edition.id, db)
if user_team:
if user_team.second_id:
raise HTTPException(status_code=403, detail="You are already in a team.")
-
await cruds_raid.delete_team(user_team.id, db)
team = await cruds_raid.get_team_by_id(invite_token.team_id, db)
-
if not team:
raise HTTPException(status_code=404, detail="Team not found.")
-
if team.second_id:
raise HTTPException(status_code=403, detail="Team is already full.")
-
if team.captain_id == user.id:
raise HTTPException(
status_code=403,
@@ -731,39 +938,31 @@ async def join_team(
@module.router.post(
- "/raid/teams/{team_id}/kick/{participant_id}",
+ "/raid/teams/{team_id}/kick/{user_id}",
response_model=schemas_raid.RaidTeam,
status_code=201,
)
async def kick_team_member(
team_id: str,
- participant_id: str,
+ user_id: str,
db: AsyncSession = Depends(get_db),
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
):
- """
- Leave a team
- """
team = await cruds_raid.get_team_by_id(team_id, db)
if not team:
raise HTTPException(status_code=404, detail="Team not found.")
- if team.captain_id == participant_id:
+ if team.captain_id == user_id:
if not team.second_id:
raise HTTPException(
status_code=403,
detail="You can not kick the only member of the team.",
)
- await cruds_raid.update_team_captain_id(
- team_id,
- team.second_id,
- db,
- )
- elif team.second_id != participant_id:
+ await cruds_raid.update_team_captain_id(team_id, team.second_id, db)
+ elif team.second_id != user_id:
raise HTTPException(status_code=404, detail="Participant not found.")
await cruds_raid.update_team_second_id(team_id, None, db)
-
return await cruds_raid.get_team_by_id(team_id, db)
@@ -780,9 +979,6 @@ async def merge_teams(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
):
- """
- Merge two teams
- """
team1 = await cruds_raid.get_team_by_id(team1_id, db)
team2 = await cruds_raid.get_team_by_id(team2_id, db)
if not team1 or not team2:
@@ -799,7 +995,7 @@ async def merge_teams(
new_number = (
min(team1.number, team2.number) if team1.number and team2.number else None
)
- team_update: schemas_raid.RaidTeamUpdate = schemas_raid.RaidTeamUpdate(
+ team_update = schemas_raid.RaidTeamUpdate(
name=new_name,
difficulty=new_difficulty,
meeting_place=new_meeting_place,
@@ -807,17 +1003,17 @@ async def merge_teams(
)
await cruds_raid.delete_team_invite_tokens(team1_id, db)
await cruds_raid.delete_team_invite_tokens(team2_id, db)
- await cruds_raid.update_team(
- team1_id,
- team_update,
- db,
- )
+ await cruds_raid.update_team(team1_id, team_update, db)
await cruds_raid.update_team_second_id(team1_id, team2.captain_id, db)
await cruds_raid.delete_team(team2_id, db)
-
return await cruds_raid.get_team_by_id(team1_id, db)
+# ---------------------------------------------------------------------------
+# Configuration (coredata)
+# ---------------------------------------------------------------------------
+
+
@module.router.get(
"/raid/information",
response_model=coredata_raid.RaidInformation,
@@ -829,9 +1025,6 @@ async def get_raid_information(
is_user_allowed_to([RaidPermissions.access_raid]),
),
):
- """
- Get raid information
- """
return await get_core_data(coredata_raid.RaidInformation, db)
@@ -845,64 +1038,27 @@ async def update_raid_information(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Update raid information
- """
- # Checking the last saved information is a temporary fix for core data not supporting exclude None on update
last_information = await get_core_data(coredata_raid.RaidInformation, db)
await set_core_data(raid_information, db)
if (
raid_information.raid_start_date
and raid_information.raid_start_date != last_information.raid_start_date
):
- participants = await cruds_raid.get_all_participants(db)
+ participants = await cruds_raid.get_all_participants(edition.id, db)
for participant in participants:
- is_minor = will_participant_be_minor_on(
- participant=participant,
+ birthday = participant.user.birthday if participant.user else None
+ is_minor = will_birthday_be_minor_on(
+ birthday=birthday,
raid_start_date=raid_information.raid_start_date,
)
- await cruds_raid.update_participant_minority(participant.id, is_minor, db)
-
-
-@module.router.patch(
- "/raid/drive",
- status_code=204,
-)
-async def update_drive_folders(
- drive_folders: schemas_raid.RaidDriveFoldersCreation,
- db: AsyncSession = Depends(get_db),
- user: models_users.CoreUser = Depends(
- is_user_allowed_to([RaidPermissions.manage_raid]),
- ),
-):
- """
- Update drive folders
- """
- schemas_folders = await get_core_data(coredata_raid.RaidDriveFolders, db)
- schemas_folders = coredata_raid.RaidDriveFolders(
- parent_folder_id=drive_folders.parent_folder_id,
- registering_folder_id=None,
- security_folder_id=None,
- )
- await set_core_data(schemas_folders, db)
-
-
-@module.router.get(
- "/raid/drive",
- response_model=schemas_raid.RaidDriveFoldersCreation,
- status_code=200,
-)
-async def get_drive_folders(
- db: AsyncSession = Depends(get_db),
- user: models_users.CoreUser = Depends(
- is_user_allowed_to([RaidPermissions.manage_raid]),
- ),
-):
- """
- Get drive folders
- """
- return await get_core_data(coredata_raid.RaidDriveFolders, db)
+ await cruds_raid.update_participant_minority(
+ participant.user_id,
+ edition.id,
+ is_minor,
+ db,
+ )
@module.router.get(
@@ -916,9 +1072,6 @@ async def get_raid_price(
is_user_allowed_to([RaidPermissions.access_raid]),
),
):
- """
- Get raid price
- """
return await get_core_data(coredata_raid.RaidPrice, db)
@@ -933,12 +1086,14 @@ async def update_raid_price(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
):
- """
- Update raid price
- """
await set_core_data(raid_price, db)
+# ---------------------------------------------------------------------------
+# Payment URL
+# ---------------------------------------------------------------------------
+
+
@module.router.get(
"/raid/pay",
response_model=schemas_raid.PaymentUrl,
@@ -950,11 +1105,8 @@ async def get_payment_url(
is_user_allowed_to([RaidPermissions.access_raid]),
),
payment_tool: PaymentTool = Depends(get_payment_tool(HelloAssoConfigName.RAID)),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Get payment url
- """
-
raid_prices = await get_core_data(coredata_raid.RaidPrice, db)
if (
not raid_prices.student_price
@@ -963,11 +1115,12 @@ async def get_payment_url(
):
raise HTTPException(status_code=404, detail="Prices not set.")
- participant = await cruds_raid.get_participant_by_id(user.id, db)
+ participant = await cruds_raid.get_participant_by_user_id(user.id, edition.id, db)
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant.")
price, checkout_name = calculate_raid_payment(participant, raid_prices)
- user_dict = user.__dict__
+
+ user_dict = {k: v for k, v in user.__dict__.items() if not k.startswith("_")}
user_dict.pop("school", None)
checkout = await payment_tool.init_checkout(
module=module.root,
@@ -978,17 +1131,19 @@ async def get_payment_url(
)
hyperion_error_logger.info(f"RAID: Logging Checkout id {checkout.id}")
await cruds_raid.create_participant_checkout(
- models_raid.RaidParticipantCheckout(
- id=str(uuid.uuid4()),
- participant_id=user.id,
- # TODO: use UUID
+ schemas_raid.RaidParticipantCheckout(
+ participant_user_id=user.id,
+ edition_id=edition.id,
checkout_id=str(checkout.id),
),
db=db,
)
- return schemas_raid.PaymentUrl(
- url=checkout.payment_url,
- )
+ return schemas_raid.PaymentUrl(url=checkout.payment_url)
+
+
+# ---------------------------------------------------------------------------
+# Bulk downloads
+# ---------------------------------------------------------------------------
@module.router.get(
@@ -1001,13 +1156,10 @@ async def download_security_files_zip(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Generate and serve a ZIP file containing all security files.
- Only accessible to raid admins.
- """
information = await get_core_data(coredata_raid.RaidInformation, db)
- zip_file_path = await get_all_security_files_zip(db, information)
+ zip_file_path = await get_all_security_files_zip(db, information, edition.id)
return FileResponse(
zip_file_path,
media_type="application/zip",
@@ -1025,15 +1177,187 @@ async def download_team_files_zip(
user: models_users.CoreUser = Depends(
is_user_allowed_to([RaidPermissions.manage_raid]),
),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
):
- """
- Generate and serve a ZIP file containing all team files.
- Only accessible to raid admins.
- """
information = await get_core_data(coredata_raid.RaidInformation, db)
- zip_file_path = await get_all_team_files_zip(db, information)
+ zip_file_path = await get_all_team_files_zip(db, information, edition.id)
return FileResponse(
zip_file_path,
media_type="application/zip",
filename=Path(zip_file_path).name,
)
+
+
+# ---------------------------------------------------------------------------
+# Volunteers
+# ---------------------------------------------------------------------------
+
+
+@module.router.post(
+ "/raid/volunteers",
+ response_model=schemas_raid.RaidVolunteer,
+ status_code=201,
+)
+async def create_volunteer(
+ volunteer: schemas_raid.RaidVolunteerBase,
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.access_raid]),
+ ),
+ db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ if await cruds_raid.get_volunteer_by_user_id(user.id, edition.id, db):
+ raise HTTPException(status_code=403, detail="You are already a volunteer.")
+ await ensure_user_is_not_participant_in_edition(user.id, edition.id, db)
+
+ volunteer_create = schemas_raid.RaidVolunteerCreate(
+ user_id=user.id,
+ edition_id=edition.id,
+ created_at=datetime.now(UTC),
+ validated=False,
+ cancelled=False,
+ t_shirt_size=volunteer.t_shirt_size,
+ diet=volunteer.diet,
+ allergy=volunteer.allergy,
+ emergency_person_name=volunteer.emergency_person_name,
+ emergency_person_phone=volunteer.emergency_person_phone,
+ has_car=volunteer.has_car,
+ car_seats=volunteer.car_seats,
+ is_special_driver=volunteer.is_special_driver,
+ is_utility_vehicle_driver=volunteer.is_utility_vehicle_driver,
+ is_parcours_helper=volunteer.is_parcours_helper,
+ )
+ await cruds_raid.create_volunteer(volunteer_create, db)
+ return await get_volunteer_or_404(user.id, edition.id, db)
+
+
+@module.router.get(
+ "/raid/volunteers/me",
+ response_model=schemas_raid.RaidVolunteer,
+ status_code=200,
+)
+async def get_my_volunteer(
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.access_raid]),
+ ),
+ db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ return await get_volunteer_or_404(user.id, edition.id, db)
+
+
+@module.router.get(
+ "/raid/participants",
+ response_model=list[schemas_raid.RaidParticipant],
+ status_code=200,
+)
+async def list_participants(
+ status: RaidRegistrationStatus | None = None,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.manage_raid]),
+ ),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ return await cruds_raid.get_all_participants(edition.id, db, status)
+
+
+@module.router.get(
+ "/raid/volunteers",
+ response_model=list[schemas_raid.RaidVolunteer],
+ status_code=200,
+)
+async def list_volunteers(
+ validated: bool | None = None,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.manage_raid]),
+ ),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ return await cruds_raid.get_all_volunteers_by_edition(edition.id, db, validated)
+
+
+@module.router.patch(
+ "/raid/volunteers/{user_id}",
+ status_code=204,
+)
+async def update_volunteer(
+ user_id: str,
+ volunteer_edit: schemas_raid.RaidVolunteerEdit,
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.access_raid]),
+ ),
+ db: AsyncSession = Depends(get_db),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ if user.id != user_id and not is_admin:
+ raise HTTPException(status_code=403, detail="You are not the volunteer.")
+ existing = await get_volunteer_or_404(user_id, edition.id, db)
+ if existing.validated and not is_admin:
+ raise HTTPException(
+ status_code=400,
+ detail="Volunteer is validated; admin-only update",
+ )
+ await cruds_raid.update_volunteer(user_id, edition.id, volunteer_edit, db)
+
+
+@module.router.patch(
+ "/raid/volunteers/{user_id}/validate",
+ status_code=204,
+)
+async def validate_volunteer(
+ user_id: str,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.manage_raid]),
+ ),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ volunteer = await get_volunteer_or_404(user_id, edition.id, db)
+ await check_volunteer_validation_consistency(volunteer, edition.id, db)
+ await cruds_raid.update_volunteer_validation(user_id, edition.id, True, db)
+
+
+@module.router.patch(
+ "/raid/volunteers/{user_id}/cancel",
+ status_code=204,
+)
+async def cancel_volunteer(
+ user_id: str,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.access_raid]),
+ ),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ if user.id != user_id and not is_admin:
+ raise HTTPException(status_code=403, detail="You are not the volunteer.")
+ await get_volunteer_or_404(user_id, edition.id, db)
+ await cruds_raid.update_volunteer_cancellation(user_id, edition.id, True, db)
+
+
+@module.router.delete(
+ "/raid/volunteers/{user_id}",
+ status_code=204,
+)
+async def delete_volunteer(
+ user_id: str,
+ db: AsyncSession = Depends(get_db),
+ user: models_users.CoreUser = Depends(
+ is_user_allowed_to([RaidPermissions.access_raid]),
+ ),
+ edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition),
+):
+ is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db)
+ if user.id != user_id and not is_admin:
+ raise HTTPException(status_code=403, detail="You are not the volunteer.")
+ existing = await get_volunteer_or_404(user_id, edition.id, db)
+ if existing.validated and not is_admin:
+ raise HTTPException(
+ status_code=403,
+ detail="Cannot remove a validated volunteer (admin-only)",
+ )
+ await cruds_raid.delete_volunteer(user_id, edition.id, db)
diff --git a/app/modules/raid/factory_raid.py b/app/modules/raid/factory_raid.py
new file mode 100644
index 0000000000..a71dda8269
--- /dev/null
+++ b/app/modules/raid/factory_raid.py
@@ -0,0 +1,130 @@
+"""Factory that seeds a default raid edition plus a sample team and volunteer.
+
+Runs only when no RaidEdition exists yet (fresh install), mirroring the
+sport_competition factory's ``should_run`` contract.
+"""
+
+import uuid
+from datetime import UTC, datetime
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.groups import cruds_groups
+from app.core.groups.factory_groups import CoreGroupsFactory
+from app.core.groups.models_groups import CoreGroup, CoreMembership
+from app.core.permissions import cruds_permissions, schemas_permissions
+from app.core.users import cruds_users
+from app.core.users.factory_users import CoreUsersFactory
+from app.core.utils.config import Settings
+from app.modules.raid import cruds_raid, schemas_raid
+from app.modules.raid.raid_type import (
+ Difficulty,
+ MeetingPlace,
+ RaidRegistrationStatus,
+ Situation,
+ Size,
+)
+from app.types.factory import Factory
+
+# Stable group UUID so `FACTORIES_DEMO_USERS` in config.yaml can reference it.
+RAID_ADMIN_GROUP_ID = "7a1da1d0-0000-0000-0000-000000000001"
+RAID_ADMIN_EMAIL = "admin@raid.test"
+
+
+class RaidFactory(Factory):
+ depends_on = [CoreUsersFactory, CoreGroupsFactory]
+
+ edition_id = uuid.uuid4()
+ team_id = str(uuid.uuid4())
+
+ @classmethod
+ async def should_run(cls, db: AsyncSession) -> bool:
+ return await cruds_raid.get_all_editions(db) == []
+
+ @classmethod
+ async def _ensure_raid_admin_group(cls, db: AsyncSession) -> None:
+ """Create the raid_admin group + permission and grant it to the
+ admin demo user if config.yaml defined one."""
+ raid_admin_group = CoreGroup(
+ id=RAID_ADMIN_GROUP_ID,
+ name="raid_admin",
+ description="Raid organizers with manage_raid permission",
+ )
+ await cruds_groups.create_group(db=db, group=raid_admin_group)
+ await cruds_permissions.create_group_permission(
+ permission=schemas_permissions.CoreGroupPermission(
+ permission_name="manage_raid",
+ group_id=RAID_ADMIN_GROUP_ID,
+ ),
+ db=db,
+ )
+
+ admin_user = await cruds_users.get_user_by_email(
+ db=db,
+ email=RAID_ADMIN_EMAIL,
+ )
+ if admin_user is not None:
+ await cruds_groups.create_membership(
+ db=db,
+ membership=CoreMembership(
+ group_id=RAID_ADMIN_GROUP_ID,
+ user_id=admin_user.id,
+ description=None,
+ ),
+ )
+
+ @classmethod
+ async def run(cls, db: AsyncSession, settings: Settings) -> None:
+ await cls._ensure_raid_admin_group(db)
+
+ edition = schemas_raid.RaidEdition(
+ id=cls.edition_id,
+ year=datetime.now(UTC).year,
+ name="Raid",
+ start_date=None,
+ end_date=None,
+ registering_end_date=None,
+ active=True,
+ inscription_enabled=True,
+ )
+ await cruds_raid.create_edition(edition, db)
+
+ seed_users = CoreUsersFactory.other_users_id[:3]
+ if len(seed_users) < 3:
+ return
+
+ captain_id, second_id, volunteer_id = seed_users
+
+ for idx, uid in enumerate((captain_id, second_id)):
+ participant = schemas_raid.RaidParticipantCreate(
+ user_id=uid,
+ edition_id=cls.edition_id,
+ status=RaidRegistrationStatus.submitted,
+ address=f"{idx + 1} rue de la Doua",
+ bike_size=Size.M,
+ t_shirt_size=Size.M,
+ situation=Situation.centrale,
+ attestation_on_honour=True,
+ )
+ await cruds_raid.create_participant(participant, db)
+
+ team = schemas_raid.RaidTeamCreate(
+ id=cls.team_id,
+ edition_id=cls.edition_id,
+ name="Team Seed",
+ difficulty=Difficulty.sports,
+ captain_id=captain_id,
+ second_id=second_id,
+ meeting_place=MeetingPlace.centrale,
+ )
+ await cruds_raid.create_team(team, db)
+
+ volunteer = schemas_raid.RaidVolunteerCreate(
+ user_id=volunteer_id,
+ edition_id=cls.edition_id,
+ created_at=datetime.now(UTC),
+ has_car=True,
+ car_seats=4,
+ is_parcours_helper=True,
+ )
+ await cruds_raid.create_volunteer(volunteer, db)
diff --git a/app/modules/raid/models_raid.py b/app/modules/raid/models_raid.py
index fceff2d839..4ae59c132f 100644
--- a/app/modules/raid/models_raid.py
+++ b/app/modules/raid/models_raid.py
@@ -1,18 +1,35 @@
"""Models file for module_raid"""
-from datetime import date
+from datetime import date, datetime
+from uuid import UUID
-from sqlalchemy import ForeignKey
+from sqlalchemy import ForeignKey, ForeignKeyConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
+from app.core.users.models_users import CoreUser
from app.modules.raid.raid_type import (
Difficulty,
DocumentType,
DocumentValidation,
MeetingPlace,
+ RaidRegistrationStatus,
+ Situation,
Size,
)
-from app.types.sqlalchemy import Base
+from app.types.sqlalchemy import Base, PrimaryKey
+
+
+class RaidEdition(Base):
+ __tablename__ = "raid_edition"
+
+ id: Mapped[PrimaryKey]
+ year: Mapped[int]
+ name: Mapped[str]
+ start_date: Mapped[date | None]
+ end_date: Mapped[date | None]
+ registering_end_date: Mapped[date | None]
+ active: Mapped[bool]
+ inscription_enabled: Mapped[bool]
class Document(Base):
@@ -21,6 +38,7 @@ class Document(Base):
primary_key=True,
index=True,
)
+ edition_id: Mapped[UUID] = mapped_column(ForeignKey("raid_edition.id"))
name: Mapped[str]
uploaded_at: Mapped[date]
type: Mapped[DocumentType]
@@ -35,6 +53,7 @@ class SecurityFile(Base):
primary_key=True,
index=True,
)
+ edition_id: Mapped[UUID] = mapped_column(ForeignKey("raid_edition.id"))
allergy: Mapped[str | None]
asthma: Mapped[bool]
intensive_care_unit: Mapped[bool | None]
@@ -72,19 +91,22 @@ def validation(self) -> DocumentValidation:
class RaidParticipant(Base):
__tablename__ = "raid_participant"
- id: Mapped[str] = mapped_column(
+ user_id: Mapped[str] = mapped_column(
+ ForeignKey("core_user.id"),
primary_key=True,
index=True,
)
- name: Mapped[str]
- firstname: Mapped[str]
- birthday: Mapped[date]
- phone: Mapped[str]
- email: Mapped[str]
+ edition_id: Mapped[UUID] = mapped_column(
+ ForeignKey("raid_edition.id"),
+ primary_key=True,
+ )
+ status: Mapped[RaidRegistrationStatus] = mapped_column(
+ default=RaidRegistrationStatus.draft,
+ )
address: Mapped[str | None] = mapped_column(default=None)
bike_size: Mapped[Size | None] = mapped_column(default=None)
t_shirt_size: Mapped[Size | None] = mapped_column(default=None)
- situation: Mapped[str | None] = mapped_column(default=None)
+ situation: Mapped[Situation | None] = mapped_column(default=None)
other_school: Mapped[str | None] = mapped_column(default=None)
company: Mapped[str | None] = mapped_column(default=None)
diet: Mapped[str | None] = mapped_column(default=None)
@@ -141,107 +163,16 @@ class RaidParticipant(Base):
foreign_keys=[parent_authorization_id],
init=False,
)
- attestation_on_honour: Mapped[bool] = mapped_column(
- default=False,
- )
+ attestation_on_honour: Mapped[bool] = mapped_column(default=False)
payment: Mapped[bool] = mapped_column(default=False)
- t_shirt_payment: Mapped[bool] = mapped_column(
- default=False,
- )
+ t_shirt_payment: Mapped[bool] = mapped_column(default=False)
is_minor: Mapped[bool] = mapped_column(default=False)
- @property
- def number_of_document(self) -> int:
- number_total = 3
- if self.situation and self.situation.split(" : ")[0] in [
- "centrale",
- "otherschool",
- ]:
- number_total += 1
- if self.is_minor:
- number_total += 1
- return number_total
-
- @property
- def number_of_validated_document(self) -> int:
- number_validated = 0
- if (
- self.situation
- and self.situation.split(" : ")[0] in ["centrale", "otherschool"]
- and self.student_card
- and self.student_card.validation == DocumentValidation.accepted
- ):
- number_validated += 1
- if self.id_card and self.id_card.validation == DocumentValidation.accepted:
- number_validated += 1
- if (
- self.medical_certificate
- and self.medical_certificate.validation == DocumentValidation.accepted
- ):
- number_validated += 1
- if (
- self.raid_rules
- and self.raid_rules.validation == DocumentValidation.accepted
- ):
- number_validated += 1
- if (
- self.is_minor
- and self.parent_authorization
- and self.parent_authorization.validation == DocumentValidation.accepted
- ):
- number_validated += 1
- return number_validated
-
- @property
- def validation_progress(self) -> float:
- number_total = 10
- conditions = [
- self.address,
- self.bike_size,
- self.t_shirt_size,
- self.situation,
- self.attestation_on_honour,
- ]
- number_validated: float = sum(
- [condition is not None for condition in conditions],
- )
- if self.situation and self.situation.split(" : ")[0] in [
- "centrale",
- "otherschool",
- ]:
- number_total += 1
- if (
- self.student_card
- and self.student_card.validation == DocumentValidation.accepted
- ):
- number_validated += 1
- if self.is_minor:
- number_total += 1
- if self.parent_authorization:
- if self.parent_authorization.validation == DocumentValidation.accepted:
- number_validated += 1
- elif (
- self.parent_authorization.validation == DocumentValidation.temporary
- ):
- number_validated += 0.5
- if self.id_card and self.id_card.validation == DocumentValidation.accepted:
- number_validated += 1
- if self.medical_certificate:
- if self.medical_certificate.validation == DocumentValidation.accepted:
- number_validated += 1
- elif self.medical_certificate.validation == DocumentValidation.temporary:
- number_validated += 0.5
- if self.security_file and self.security_file:
- if self.security_file.validation == DocumentValidation.accepted:
- number_validated += 1
- elif self.security_file.validation == DocumentValidation.temporary:
- number_validated += 0.5
- if (
- self.raid_rules
- and self.raid_rules.validation == DocumentValidation.accepted
- ):
- number_validated += 1
- return (number_validated / number_total) * 100
+ user: Mapped[CoreUser] = relationship(
+ "CoreUser",
+ lazy="joined",
+ init=False,
+ )
class RaidTeam(Base):
@@ -250,41 +181,44 @@ class RaidTeam(Base):
primary_key=True,
index=True,
)
+ edition_id: Mapped[UUID] = mapped_column(ForeignKey("raid_edition.id"))
name: Mapped[str]
difficulty: Mapped[Difficulty | None]
- captain_id: Mapped[str] = mapped_column(
- ForeignKey("raid_participant.id"),
- )
- second_id: Mapped[str | None] = mapped_column(
- ForeignKey("raid_participant.id"),
- default=None,
- )
+ captain_id: Mapped[str]
+ second_id: Mapped[str | None] = mapped_column(default=None)
number: Mapped[int | None] = mapped_column(default=None)
captain: Mapped[RaidParticipant] = relationship(
"RaidParticipant",
- foreign_keys=[captain_id],
+ foreign_keys="[RaidTeam.captain_id, RaidTeam.edition_id]",
init=False,
+ overlaps="second",
)
- second: Mapped[RaidParticipant] = relationship(
+ second: Mapped[RaidParticipant | None] = relationship(
"RaidParticipant",
- foreign_keys=[second_id],
+ foreign_keys="[RaidTeam.second_id, RaidTeam.edition_id]",
init=False,
+ overlaps="captain",
)
meeting_place: Mapped[MeetingPlace | None] = mapped_column(default=None)
file_id: Mapped[str | None] = mapped_column(default=None)
- @property
- def validation_progress(self) -> float:
- number_validated = 0
- number_total = 2
- if self.difficulty:
- number_validated += 1
- if self.meeting_place:
- number_validated += 1
- return (number_validated / number_total) * 10 + (
- self.captain.validation_progress
- + (self.second.validation_progress if self.second else 0)
- ) * 0.45
+ __table_args__ = (
+ ForeignKeyConstraint(
+ ["captain_id", "edition_id"],
+ ["raid_participant.user_id", "raid_participant.edition_id"],
+ name="fk_raid_team_captain",
+ ),
+ ForeignKeyConstraint(
+ ["second_id", "edition_id"],
+ ["raid_participant.user_id", "raid_participant.edition_id"],
+ name="fk_raid_team_second",
+ ),
+ UniqueConstraint(
+ "second_id",
+ "edition_id",
+ name="uq_raid_team_second_id_edition_id",
+ ),
+ )
class InviteToken(Base):
@@ -293,6 +227,7 @@ class InviteToken(Base):
primary_key=True,
index=True,
)
+ edition_id: Mapped[UUID] = mapped_column(ForeignKey("raid_edition.id"))
team_id: Mapped[str] = mapped_column(ForeignKey("raid_team.id"))
token: Mapped[str]
@@ -303,7 +238,45 @@ class RaidParticipantCheckout(Base):
primary_key=True,
index=True,
)
- participant_id: Mapped[str] = mapped_column(
- ForeignKey("raid_participant.id"),
- )
+ participant_user_id: Mapped[str]
+ edition_id: Mapped[UUID]
checkout_id: Mapped[str] = mapped_column(ForeignKey("checkout_checkout.id"))
+
+ __table_args__ = (
+ ForeignKeyConstraint(
+ ["participant_user_id", "edition_id"],
+ ["raid_participant.user_id", "raid_participant.edition_id"],
+ name="fk_raid_participant_checkout_participant",
+ ),
+ )
+
+
+class RaidVolunteer(Base):
+ __tablename__ = "raid_volunteer"
+ user_id: Mapped[str] = mapped_column(
+ ForeignKey("core_user.id"),
+ primary_key=True,
+ )
+ edition_id: Mapped[UUID] = mapped_column(
+ ForeignKey("raid_edition.id"),
+ primary_key=True,
+ )
+ created_at: Mapped[datetime]
+ validated: Mapped[bool]
+ cancelled: Mapped[bool]
+ diet: Mapped[str | None] = mapped_column(default=None)
+ allergy: Mapped[str | None] = mapped_column(default=None)
+ t_shirt_size: Mapped[Size | None] = mapped_column(default=None)
+ emergency_person_name: Mapped[str | None] = mapped_column(default=None)
+ emergency_person_phone: Mapped[str | None] = mapped_column(default=None)
+ has_car: Mapped[bool] = mapped_column(default=False)
+ car_seats: Mapped[int | None] = mapped_column(default=None)
+ is_special_driver: Mapped[bool] = mapped_column(default=False)
+ is_utility_vehicle_driver: Mapped[bool] = mapped_column(default=False)
+ is_parcours_helper: Mapped[bool] = mapped_column(default=False)
+
+ user: Mapped[CoreUser] = relationship(
+ "CoreUser",
+ lazy="joined",
+ init=False,
+ )
diff --git a/app/modules/raid/raid_type.py b/app/modules/raid/raid_type.py
index d611ada07b..1eb42f1f7d 100644
--- a/app/modules/raid/raid_type.py
+++ b/app/modules/raid/raid_type.py
@@ -33,10 +33,10 @@ class Difficulty(StrEnum): # the difficulty of the raid
class Situation(StrEnum): # the situation of the participant
- centrale = "centrale"
- otherSchool = "otherSchool"
- corporatePartner = "corporatePartner"
- other = "other"
+ centrale = "centrale" # student from Centrale Lyon
+ otherSchool = "otherSchool" # student from another school
+ corporatePartner = "corporatePartner" # enterprise team
+ other = "other" # anything else
class DocumentValidation(StrEnum):
@@ -44,3 +44,10 @@ class DocumentValidation(StrEnum):
accepted = "accepted"
refused = "refused"
temporary = "temporary"
+
+
+class RaidRegistrationStatus(StrEnum):
+ draft = "draft"
+ submitted = "submitted"
+ validated = "validated"
+ cancelled = "cancelled"
diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py
index 043b52d1e9..163b6bbc15 100644
--- a/app/modules/raid/schemas_raid.py
+++ b/app/modules/raid/schemas_raid.py
@@ -1,14 +1,29 @@
-from datetime import date
-
-from pydantic import BaseModel
+from datetime import date, datetime
+from uuid import UUID
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ computed_field,
+ field_validator,
+ model_validator,
+)
+from app.core.users.schemas_users import CoreUser
from app.modules.raid.raid_type import (
Difficulty,
DocumentType,
DocumentValidation,
MeetingPlace,
+ RaidRegistrationStatus,
+ Situation,
Size,
)
+from app.modules.raid.utils.validation_checker import (
+ compute_participant_progress,
+ count_accepted_documents,
+ count_total_required_documents,
+)
class DocumentBase(BaseModel):
@@ -30,6 +45,8 @@ class Document(DocumentBase):
uploaded_at: date
validation: DocumentValidation
+ model_config = ConfigDict(from_attributes=True)
+
class SecurityFileBase(BaseModel):
allergy: str | None = None
@@ -52,52 +69,98 @@ class SecurityFile(SecurityFileBase):
validation: DocumentValidation
id: str
+ model_config = ConfigDict(from_attributes=True)
+
class RaidParticipantBase(BaseModel):
- name: str
- firstname: str
- birthday: date
- phone: str
- email: str
+ """Shape used when the user first self-enrols.
+
+ Identity fields (name, firstname, email, birthday, phone) are not here:
+ they live on CoreUser and are read via the `user` relationship on the
+ read schemas below.
+ """
+
+
+class RaidParticipantCreate(BaseModel):
+ """Flat column-level payload used by the create CRUD."""
+
+ user_id: str
+ edition_id: UUID
+ status: RaidRegistrationStatus
+ address: str | None = None
+ bike_size: Size | None = None
+ t_shirt_size: Size | None = None
+ situation: Situation | None = None
+ other_school: str | None = None
+ company: str | None = None
+ diet: str | None = None
+ id_card_id: str | None = None
+ medical_certificate_id: str | None = None
+ security_file_id: str | None = None
+ student_card_id: str | None = None
+ raid_rules_id: str | None = None
+ parent_authorization_id: str | None = None
+ attestation_on_honour: bool = False
+ payment: bool = False
+ t_shirt_payment: bool = False
+ is_minor: bool = False
class RaidParticipantPreview(RaidParticipantBase):
- id: str
- bike_size: Size | None
- t_shirt_size: Size | None
- situation: str | None
- validation_progress: float
+ user_id: str
+ edition_id: UUID
+ status: RaidRegistrationStatus
+ bike_size: Size | None = None
+ t_shirt_size: Size | None = None
+ situation: Situation | None = None
payment: bool
t_shirt_payment: bool
- number_of_document: int
- number_of_validated_document: int
+ user: CoreUser
+
+ model_config = ConfigDict(from_attributes=True)
class RaidParticipant(RaidParticipantPreview):
- address: str | None
+ address: str | None = None
other_school: str | None = None
company: str | None = None
diet: str | None = None
- id_card: Document | None
- medical_certificate: Document | None
- security_file: SecurityFile | None
+ id_card_id: str | None = None
+ id_card: Document | None = None
+ medical_certificate_id: str | None = None
+ medical_certificate: Document | None = None
+ security_file_id: str | None = None
+ security_file: SecurityFile | None = None
+ student_card_id: str | None = None
student_card: Document | None = None
+ raid_rules_id: str | None = None
raid_rules: Document | None = None
+ parent_authorization_id: str | None = None
parent_authorization: Document | None = None
attestation_on_honour: bool
is_minor: bool
+ @computed_field # type: ignore[prop-decorator]
+ @property
+ def validation_progress(self) -> float:
+ return compute_participant_progress(self)
+
+ @computed_field # type: ignore[prop-decorator]
+ @property
+ def number_of_document(self) -> int:
+ return count_total_required_documents(self)
+
+ @computed_field # type: ignore[prop-decorator]
+ @property
+ def number_of_validated_document(self) -> int:
+ return count_accepted_documents(self)
+
class RaidParticipantUpdate(BaseModel):
- name: str | None = None
- firstname: str | None = None
- birthday: date | None = None
address: str | None = None
- phone: str | None = None
- email: str | None = None
bike_size: Size | None = None
t_shirt_size: Size | None = None
- situation: str | None = None
+ situation: Situation | None = None
other_school: str | None = None
company: str | None = None
diet: str | None = None
@@ -109,6 +172,32 @@ class RaidParticipantUpdate(BaseModel):
raid_rules_id: str | None = None
parent_authorization_id: str | None = None
+ @field_validator("situation", mode="before")
+ @classmethod
+ def _coerce_legacy_situation(cls, value):
+ """Accept the legacy lowercase `otherschool` during the grace period."""
+ if isinstance(value, str):
+ if value.startswith("otherschool"):
+ return Situation.otherSchool
+ if value == "otherSchool":
+ return Situation.otherSchool
+ if value == "centrale":
+ return Situation.centrale
+ if value == "corporatePartner":
+ return Situation.corporatePartner
+ if value == "other":
+ return Situation.other
+ return value
+
+ @model_validator(mode="after")
+ def _check_situation_consistency(self):
+ if self.situation == Situation.otherSchool and self.other_school is None:
+ msg = "situation=otherSchool requires other_school to be set"
+ raise ValueError(msg)
+ if self.situation == Situation.centrale and self.other_school:
+ self.other_school = None
+ return self
+
class RaidTeamBase(BaseModel):
name: str
@@ -116,23 +205,69 @@ class RaidTeamBase(BaseModel):
class RaidTeamPreview(RaidTeamBase):
id: str
- number: int | None
+ edition_id: UUID
+ number: int | None = None
+ captain_id: str
captain: RaidParticipantPreview
- second: RaidParticipantPreview | None
- difficulty: Difficulty | None
- meeting_place: MeetingPlace | None
- validation_progress: float
+ second_id: str | None = None
+ second: RaidParticipantPreview | None = None
+ difficulty: Difficulty | None = None
+ meeting_place: MeetingPlace | None = None
+
+ model_config = ConfigDict(from_attributes=True)
+
+ @computed_field # type: ignore[prop-decorator]
+ @property
+ def validation_progress(self) -> float:
+ captain_progress = (
+ self.captain.validation_progress
+ if isinstance(self.captain, RaidParticipant)
+ else 0
+ )
+ second_progress = (
+ self.second.validation_progress
+ if isinstance(self.second, RaidParticipant)
+ else 0
+ )
+ filled = int(self.difficulty is not None) + int(self.meeting_place is not None)
+ return (filled / 2) * 10 + (captain_progress + second_progress) * 0.45
class RaidTeam(RaidTeamBase):
id: str
- number: int | None
+ edition_id: UUID
+ number: int | None = None
+ captain_id: str
captain: RaidParticipant
- second: RaidParticipant | None
- difficulty: Difficulty | None
- meeting_place: MeetingPlace | None
- validation_progress: float
- file_id: str | None
+ second_id: str | None = None
+ second: RaidParticipant | None = None
+ difficulty: Difficulty | None = None
+ meeting_place: MeetingPlace | None = None
+ file_id: str | None = None
+
+ model_config = ConfigDict(from_attributes=True)
+
+ @computed_field # type: ignore[prop-decorator]
+ @property
+ def validation_progress(self) -> float:
+ captain_progress = self.captain.validation_progress
+ second_progress = self.second.validation_progress if self.second else 0
+ filled = int(self.difficulty is not None) + int(self.meeting_place is not None)
+ return (filled / 2) * 10 + (captain_progress + second_progress) * 0.45
+
+
+class RaidTeamCreate(BaseModel):
+ """Flat column-level payload used by the create CRUD."""
+
+ id: str
+ edition_id: UUID
+ name: str
+ captain_id: str
+ second_id: str | None = None
+ difficulty: Difficulty | None = None
+ meeting_place: MeetingPlace | None = None
+ number: int | None = None
+ file_id: str | None = None
class RaidTeamUpdate(BaseModel):
@@ -143,9 +278,13 @@ class RaidTeamUpdate(BaseModel):
class InviteToken(BaseModel):
+ id: str
+ edition_id: UUID
team_id: str
token: str
+ model_config = ConfigDict(from_attributes=True)
+
class EmergencyContact(BaseModel):
firstname: str | None = None
@@ -153,14 +292,114 @@ class EmergencyContact(BaseModel):
phone: str | None = None
-class RaidDriveFoldersCreation(BaseModel):
- parent_folder_id: str
-
-
class PaymentUrl(BaseModel):
url: str
class RaidParticipantCheckout(BaseModel):
- participant_id: str
+ participant_user_id: str
+ edition_id: UUID
checkout_id: str
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class RaidEditionBase(BaseModel):
+ name: str
+ year: int
+ start_date: date | None = None
+ end_date: date | None = None
+ registering_end_date: date | None = None
+ active: bool = False
+ inscription_enabled: bool = False
+
+
+class RaidEdition(RaidEditionBase):
+ id: UUID
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class RaidEditionEdit(BaseModel):
+ name: str | None = None
+ year: int | None = None
+ start_date: date | None = None
+ end_date: date | None = None
+ registering_end_date: date | None = None
+ active: bool | None = None
+ inscription_enabled: bool | None = None
+
+
+class RaidVolunteerBase(BaseModel):
+ """Shared volunteer fields.
+
+ The car_seats/has_car consistency check is enforced only on the
+ write-side schemas (RaidVolunteerCreate, RaidVolunteerEdit). Reads
+ pass whatever is stored through unchanged so the admin validation
+ endpoint can surface inconsistent rows for manual fix-up.
+ """
+
+ t_shirt_size: Size | None = None
+ diet: str | None = None
+ allergy: str | None = None
+ emergency_person_name: str | None = None
+ emergency_person_phone: str | None = None
+ has_car: bool = False
+ car_seats: int | None = None
+ is_special_driver: bool = False
+ is_utility_vehicle_driver: bool = False
+ is_parcours_helper: bool = False
+
+
+def _validate_car_seats(self):
+ if self.has_car and (self.car_seats is None or self.car_seats <= 0):
+ msg = "has_car=True requires car_seats > 0"
+ raise ValueError(msg)
+ if not self.has_car:
+ self.car_seats = None
+ return self
+
+
+class RaidVolunteerCreate(RaidVolunteerBase):
+ """Flat column-level payload used by the create CRUD (no CoreUser)."""
+
+ user_id: str
+ edition_id: UUID
+ created_at: datetime
+ validated: bool = False
+ cancelled: bool = False
+
+ _check_car_seats_consistency = model_validator(mode="after")(_validate_car_seats)
+
+
+class RaidVolunteer(RaidVolunteerBase):
+ user_id: str
+ edition_id: UUID
+ created_at: datetime
+ validated: bool
+ cancelled: bool
+ user: CoreUser
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class RaidVolunteerEdit(BaseModel):
+ t_shirt_size: Size | None = None
+ diet: str | None = None
+ allergy: str | None = None
+ emergency_person_name: str | None = None
+ emergency_person_phone: str | None = None
+ has_car: bool | None = None
+ car_seats: int | None = None
+ is_special_driver: bool | None = None
+ is_utility_vehicle_driver: bool | None = None
+ is_parcours_helper: bool | None = None
+
+ @model_validator(mode="after")
+ def _check_car_seats_consistency(self):
+ if self.has_car is True and (self.car_seats is None or self.car_seats <= 0):
+ msg = "has_car=True requires car_seats > 0"
+ raise ValueError(msg)
+ if self.has_car is False:
+ self.car_seats = None
+ return self
diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py
index 8d0d89cbf7..d2f139acac 100644
--- a/app/modules/raid/utils/utils_raid.py
+++ b/app/modules/raid/utils/utils_raid.py
@@ -1,36 +1,25 @@
import logging
import zipfile
-
-# import uuid
from datetime import UTC, date, datetime
+from uuid import UUID
-import fitz
from anyio import Path
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.payment import schemas_payment
-from app.modules.raid import coredata_raid, cruds_raid, models_raid, schemas_raid
-from app.modules.raid.raid_type import Difficulty, Size
-from app.modules.raid.schemas_raid import (
- RaidParticipantBase,
- RaidParticipantUpdate,
-)
+from app.modules.raid import coredata_raid, cruds_raid, schemas_raid
+from app.modules.raid.raid_type import Difficulty, Situation, Size
from app.modules.raid.utils.pdf.conversion_utils import (
- # date_to_string,
get_difficulty_label,
- # get_document_validation_label,
get_meeting_place_label,
nullable_number_to_string,
)
+from app.modules.raid.utils.validation_checker import compute_team_progress
from app.utils.tools import (
- # concat_pdf,
- # delete_file_from_data,
generate_pdf_from_template,
get_core_data,
get_file_path_from_data,
- # get_file_path_from_data,
- # save_bytes_as_data,
)
hyperion_error_logger = logging.getLogger("hyperion.error")
@@ -41,18 +30,16 @@ def __init__(self, checkout_id):
super().__init__(f"RAID participant checkout {checkout_id} not found.")
-def will_participant_be_minor_on(
- participant: RaidParticipantUpdate
- | models_raid.RaidParticipant
- | RaidParticipantBase,
+def will_birthday_be_minor_on(
+ birthday: date | None,
raid_start_date: date | None,
) -> bool:
"""
- Determine if the participant will be minor at the RAID dates. If the date is not known, we will use January the first of next year.
+ Determine if a participant will be minor at the RAID dates. If the raid
+ date is not known, fall back to January 1st of next year. If the birthday
+ is unknown, assume they may be minor.
"""
-
- # If we don't know the participant birthday we may consider they may be minor
- if participant.birthday is None:
+ if birthday is None:
return True
if raid_start_date is None:
@@ -62,14 +49,7 @@ def will_participant_be_minor_on(
day=1,
)
- return (
- date(
- participant.birthday.year + 18,
- participant.birthday.month,
- participant.birthday.day,
- )
- > raid_start_date
- )
+ return date(birthday.year + 18, birthday.month, birthday.day) > raid_start_date
async def validate_payment(
@@ -86,14 +66,19 @@ async def validate_payment(
)
if not participant_checkout:
raise RaidPayementError(checkout_id)
- participant_id = participant_checkout.participant_id
+ participant_user_id = participant_checkout.participant_user_id
+ edition_id = participant_checkout.edition_id
prices = await get_core_data(coredata_raid.RaidPrice, db)
if (prices.student_price and paid_amount == prices.student_price) or (
prices.external_price and paid_amount == prices.external_price
):
- await cruds_raid.confirm_payment(participant_id, db)
+ await cruds_raid.confirm_payment(participant_user_id, edition_id, db)
elif prices.t_shirt_price and paid_amount == prices.t_shirt_price:
- await cruds_raid.confirm_t_shirt_payment(participant_id, db)
+ await cruds_raid.confirm_t_shirt_payment(
+ participant_user_id,
+ edition_id,
+ db,
+ )
elif prices.t_shirt_price and (
(
prices.student_price
@@ -104,17 +89,26 @@ async def validate_payment(
and paid_amount == prices.external_price + prices.t_shirt_price
)
):
- await cruds_raid.confirm_payment(participant_id, db)
- await cruds_raid.confirm_t_shirt_payment(participant_id, db)
+ await cruds_raid.confirm_payment(participant_user_id, edition_id, db)
+ await cruds_raid.confirm_t_shirt_payment(
+ participant_user_id,
+ edition_id,
+ db,
+ )
else:
hyperion_error_logger.error("Invalid payment amount")
-async def set_team_number(team: models_raid.RaidTeam, db: AsyncSession) -> None:
+async def set_team_number(
+ team: schemas_raid.RaidTeam,
+ edition_id: UUID,
+ db: AsyncSession,
+) -> None:
if team.difficulty is None:
return
- number_of_team = await cruds_raid.get_number_of_team_by_difficulty(
+ max_number = await cruds_raid.get_max_team_number_by_difficulty(
team.difficulty,
+ edition_id,
db,
)
difficulty_separator = {
@@ -123,27 +117,32 @@ async def set_team_number(team: models_raid.RaidTeam, db: AsyncSession) -> None:
Difficulty.expert: 200,
}
new_team_number = (
- difficulty_separator[team.difficulty] + 1
- if number_of_team == 0
- else number_of_team + 1
- )
- updated_team: schemas_raid.RaidTeamUpdate = schemas_raid.RaidTeamUpdate(
- number=new_team_number,
+ difficulty_separator[team.difficulty] + 1 if not max_number else max_number + 1
)
+ updated_team = schemas_raid.RaidTeamUpdate(number=new_team_number)
await cruds_raid.update_team(team.id, updated_team, db)
+def _participant_pdf_context(participant: schemas_raid.RaidParticipant) -> dict:
+ """Build a template context with identity fields pulled from CoreUser."""
+ ctx = participant.model_dump()
+ if participant.user is not None:
+ ctx["name"] = participant.user.name
+ ctx["firstname"] = participant.user.firstname
+ ctx["email"] = participant.user.email
+ ctx["phone"] = participant.user.phone
+ ctx["birthday"] = participant.user.birthday
+ return ctx
+
+
async def generate_security_file_pdf(
- participant: models_raid.RaidParticipant,
+ participant: schemas_raid.RaidParticipant,
information: coredata_raid.RaidInformation,
team_number: int | None = None,
):
- """
- Generate a security file PDF for a participant.
- The file will be saved in the `raid/security_file` directory with the participant's ID as the filename.
- """
+ """Generate a security file PDF for a participant."""
context = {
- **participant.__dict__,
+ **_participant_pdf_context(participant),
"president": information.president.__dict__ if information.president else None,
"rescue": information.rescue.__dict__ if information.rescue else None,
"security_responsible": information.security_responsible.__dict__
@@ -158,24 +157,24 @@ async def generate_security_file_pdf(
await generate_pdf_from_template(
template_name="raid_security_file.html",
directory="raid/security_file",
- filename=participant.id,
+ filename=participant.user_id,
context=context,
)
- return participant.id
+ return participant.user_id
async def generate_recap_file_pdf(
- team: models_raid.RaidTeam,
+ team: schemas_raid.RaidTeam,
):
context = {
"team_name": team.name,
"parcours": get_difficulty_label(team.difficulty),
"lieu_rdv": get_meeting_place_label(team.meeting_place),
"numero": nullable_number_to_string(team.number),
- "inscription": str(int(team.validation_progress)) + " %",
- "capitaine": team.captain.__dict__,
- "participant": team.second.__dict__ if team.second else None,
+ "inscription": str(int(compute_team_progress(team))) + " %",
+ "capitaine": _participant_pdf_context(team.captain),
+ "participant": _participant_pdf_context(team.second) if team.second else None,
}
file_id = team.id
@@ -189,28 +188,12 @@ async def generate_recap_file_pdf(
return file_id
-def scale_rect_to_fit(container, content_width, content_height):
- """Return a rect that fits content inside container preserving aspect ratio."""
- container_width = container.width
- container_height = container.height
-
- scale = min(container_width / content_width, container_height / content_height)
- new_width = content_width * scale
- new_height = content_height * scale
-
- x0 = container.x0 + (container_width - new_width) / 2
- y0 = container.y0 + (container_height - new_height) / 2
- x1 = x0 + new_width
- y1 = y0 + new_height
-
- return fitz.Rect(x0, y0, x1, y1)
-
-
async def get_all_security_files_zip(
db: AsyncSession,
information: coredata_raid.RaidInformation,
+ edition_id: UUID,
) -> str:
- teams = await cruds_raid.get_all_teams(db)
+ teams = await cruds_raid.get_all_teams(edition_id, db)
hyperion_error_logger.info(
f"RAID: Generating ZIP for {len(teams)} security files",
)
@@ -222,10 +205,7 @@ async def get_all_security_files_zip(
# TODO: iotemp file?
await Path("data/raid/").mkdir(parents=True, exist_ok=True)
zip_file_path = f"data/raid/Fiches_Sécurité_{datetime.now(UTC).strftime('%Y-%m-%d_%H_%M_%S')}.zip"
- with zipfile.ZipFile(
- zip_file_path,
- mode="w",
- ) as archive:
+ with zipfile.ZipFile(zip_file_path, mode="w") as archive:
for team in teams:
for participant in [team.captain] + ([team.second] if team.second else []):
file_id = await generate_security_file_pdf(
@@ -240,7 +220,7 @@ async def get_all_security_files_zip(
archive.write(
str(src_pdf),
- arcname=f"{team.name}_{participant.firstname}_{participant.name}.pdf",
+ arcname=f"{team.name}_{participant.user.firstname}_{participant.user.name}.pdf",
)
return zip_file_path
@@ -249,10 +229,11 @@ async def get_all_security_files_zip(
async def get_all_team_files_zip(
db: AsyncSession,
information: coredata_raid.RaidInformation,
+ edition_id: UUID,
) -> str:
- teams = await cruds_raid.get_all_teams(db)
+ teams = await cruds_raid.get_all_teams(edition_id, db)
hyperion_error_logger.info(
- f"RAID: Generating ZIP for {len(teams)} security files",
+ f"RAID: Generating ZIP for {len(teams)} team recap files",
)
if len(teams) == 0:
@@ -264,14 +245,9 @@ async def get_all_team_files_zip(
zip_file_path = (
f"data/raid/Teams_{datetime.now(UTC).strftime('%Y-%m-%d_%H_%M_%S')}.zip"
)
- with zipfile.ZipFile(
- zip_file_path,
- mode="w",
- ) as archive:
+ with zipfile.ZipFile(zip_file_path, mode="w") as archive:
for team in teams:
- file_id = await generate_recap_file_pdf(
- team,
- )
+ file_id = await generate_recap_file_pdf(team)
src_pdf = await get_file_path_from_data(
directory="raid/recap",
filename=file_id,
@@ -286,17 +262,18 @@ async def get_all_team_files_zip(
async def get_participant(
- participant_id: str,
+ user_id: str,
+ edition_id: UUID,
db: AsyncSession,
-) -> models_raid.RaidParticipant:
- participant = await cruds_raid.get_participant_by_id(participant_id, db)
+) -> schemas_raid.RaidParticipant:
+ participant = await cruds_raid.get_participant_by_user_id(user_id, edition_id, db)
if not participant:
raise HTTPException(status_code=404, detail="Participant not found.")
return participant
def calculate_raid_payment(
- participant: models_raid.RaidParticipant,
+ participant: schemas_raid.RaidParticipant,
raid_prices: coredata_raid.RaidPrice,
):
if (
@@ -308,13 +285,10 @@ def calculate_raid_payment(
price = 0
checkout_name = ""
- if not participant:
- raise HTTPException(status_code=404, detail="Participant not found.")
if not participant.payment:
if (
- participant.situation
- and participant.situation.split(" : ")[0] in ["centrale", "otherschool"]
+ participant.situation in (Situation.centrale, Situation.otherSchool)
and participant.student_card_id is not None
):
price += raid_prices.student_price
diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py
new file mode 100644
index 0000000000..8741f79460
--- /dev/null
+++ b/app/modules/raid/utils/validation_checker.py
@@ -0,0 +1,341 @@
+"""Central validation checker for raid participants and volunteers.
+
+The admin cannot flip a participant to `validated` until every sub-check
+passes; the same applies to volunteers (with a lighter gate). Each check
+raises a distinct HTTPException so the frontend can i18n cleanly.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from fastapi import HTTPException
+
+from app.modules.raid import cruds_raid
+from app.modules.raid.raid_type import (
+ DocumentValidation,
+ RaidRegistrationStatus,
+ Situation,
+ Size,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ # `schemas_raid` is only used in annotations; importing it at runtime
+ # would create a cycle (schemas_raid -> validation_checker).
+ from app.modules.raid import schemas_raid
+
+
+async def check_participant_validation_consistency(
+ participant: schemas_raid.RaidParticipant,
+ edition_id,
+ db: AsyncSession,
+) -> None:
+ """Run every gate required before an admin can set status=validated."""
+
+ _check_edition_scope(participant, edition_id)
+ _check_attestation_signed(participant)
+ _check_payment_done(participant)
+ _check_security_file_complete(participant)
+ _check_all_documents_accepted(participant)
+ await _check_team_complete(participant, db)
+
+
+def _check_edition_scope(
+ participant: schemas_raid.RaidParticipant,
+ edition_id,
+) -> None:
+ if participant.edition_id != edition_id:
+ raise HTTPException(
+ status_code=400,
+ detail="Participant does not belong to the current edition",
+ )
+
+
+def _check_attestation_signed(participant: schemas_raid.RaidParticipant) -> None:
+ if not participant.attestation_on_honour:
+ raise HTTPException(
+ status_code=400,
+ detail="Participant has not signed the attestation on honour",
+ )
+
+
+def _check_payment_done(participant: schemas_raid.RaidParticipant) -> None:
+ if not participant.payment:
+ raise HTTPException(
+ status_code=400,
+ detail="Participant payment is not done",
+ )
+ if (
+ participant.t_shirt_size is not None
+ and participant.t_shirt_size != Size.None_
+ and not participant.t_shirt_payment
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail="Participant t-shirt payment is not done",
+ )
+
+
+def _check_security_file_complete(participant: schemas_raid.RaidParticipant) -> None:
+ security_file = participant.security_file
+ if security_file is None:
+ raise HTTPException(
+ status_code=400,
+ detail="Participant has no security file",
+ )
+ if not (
+ security_file.emergency_person_firstname
+ and security_file.emergency_person_name
+ and security_file.emergency_person_phone
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail="Participant security file is missing emergency contact",
+ )
+
+
+def _check_all_documents_accepted(participant: schemas_raid.RaidParticipant) -> None:
+ _check_document_accepted(participant.id_card, "id card")
+ _check_document_accepted(participant.medical_certificate, "medical certificate")
+ _check_document_accepted(participant.raid_rules, "raid rules")
+ if participant.situation in (Situation.centrale, Situation.otherSchool):
+ _check_document_accepted(participant.student_card, "student card")
+ if participant.is_minor:
+ _check_document_accepted(
+ participant.parent_authorization,
+ "parent authorization",
+ )
+
+
+def _check_document_accepted(
+ document: schemas_raid.Document | None,
+ label: str,
+) -> None:
+ if document is None:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Missing {label}",
+ )
+ if document.validation != DocumentValidation.accepted:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Document {label} is not accepted",
+ )
+
+
+async def _check_team_complete(
+ participant: schemas_raid.RaidParticipant,
+ db: AsyncSession,
+) -> None:
+ team = await cruds_raid.get_team_by_participant_id(
+ participant.user_id,
+ participant.edition_id,
+ db,
+ )
+ if team is None:
+ raise HTTPException(
+ status_code=400,
+ detail="Participant is not in a team",
+ )
+ if team.second is None:
+ raise HTTPException(
+ status_code=400,
+ detail="Team is missing a second member",
+ )
+ if team.difficulty is None:
+ raise HTTPException(
+ status_code=400,
+ detail="Team has no chosen difficulty",
+ )
+ if team.meeting_place is None:
+ raise HTTPException(
+ status_code=400,
+ detail="Team has no chosen meeting place",
+ )
+
+
+async def check_volunteer_validation_consistency(
+ volunteer: schemas_raid.RaidVolunteer,
+ edition_id,
+ db: AsyncSession,
+) -> None:
+ if volunteer.edition_id != edition_id:
+ raise HTTPException(
+ status_code=400,
+ detail="Volunteer does not belong to the current edition",
+ )
+ if not (volunteer.user and volunteer.user.phone):
+ raise HTTPException(
+ status_code=400,
+ detail="Volunteer phone is not set on the user profile",
+ )
+ if not (volunteer.emergency_person_name and volunteer.emergency_person_phone):
+ raise HTTPException(
+ status_code=400,
+ detail="Volunteer emergency contact is incomplete",
+ )
+ if volunteer.has_car and (volunteer.car_seats is None or volunteer.car_seats <= 0):
+ raise HTTPException(
+ status_code=400,
+ detail="Volunteer has a car but car_seats is missing or invalid",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Declarative progress / required-documents rules
+# ---------------------------------------------------------------------------
+#
+# The "registration completeness" used to be hand-rolled with nested ifs and
+# magic constants (a `number_total = 10` baseline that did not actually map to
+# 10 slots once you counted them). The model below makes every slot explicit
+# so the rules can be read top-to-bottom.
+
+
+@dataclass(frozen=True)
+class _ParticipantContext:
+ """Flags that decide which document slots apply to a given participant."""
+
+ situation: Situation | None
+ is_minor: bool
+
+
+@dataclass(frozen=True)
+class _DocumentRule:
+ """One progress slot tied to a participant attribute.
+
+ `applies(context)` decides whether this slot exists for the participant
+ at all; `counts_temporary` lets a `DocumentValidation.temporary` score
+ half a slot rather than zero; `is_required_document` distinguishes actual
+ uploads (id card, medical certificate, …) from the SecurityFile form,
+ which contributes to overall progress but is not exposed in the
+ `n / total documents` counter the frontend shows.
+ """
+
+ attr: str
+ applies: Callable[[_ParticipantContext], bool]
+ counts_temporary: bool = False
+ is_required_document: bool = True
+
+
+_STUDENT_SITUATIONS = (Situation.centrale, Situation.otherSchool)
+
+_DOCUMENT_RULES: tuple[_DocumentRule, ...] = (
+ _DocumentRule("id_card", applies=lambda _c: True),
+ _DocumentRule(
+ "medical_certificate",
+ applies=lambda _c: True,
+ counts_temporary=True,
+ ),
+ _DocumentRule(
+ "security_file",
+ applies=lambda _c: True,
+ counts_temporary=True,
+ is_required_document=False,
+ ),
+ _DocumentRule("raid_rules", applies=lambda _c: True),
+ _DocumentRule(
+ "student_card",
+ applies=lambda c: c.situation in _STUDENT_SITUATIONS,
+ ),
+ _DocumentRule(
+ "parent_authorization",
+ applies=lambda c: c.is_minor,
+ counts_temporary=True,
+ ),
+)
+
+# Profile fields that each count one slot when set on the participant.
+_PROFILE_FIELDS: tuple[str, ...] = (
+ "address",
+ "bike_size",
+ "t_shirt_size",
+ "situation",
+ "attestation_on_honour",
+)
+
+
+def _context(participant: schemas_raid.RaidParticipant) -> _ParticipantContext:
+ return _ParticipantContext(
+ situation=participant.situation,
+ is_minor=participant.is_minor,
+ )
+
+
+def _applicable_rules(ctx: _ParticipantContext) -> list[_DocumentRule]:
+ return [rule for rule in _DOCUMENT_RULES if rule.applies(ctx)]
+
+
+def _score(participant: schemas_raid.RaidParticipant, rule: _DocumentRule) -> float:
+ doc = getattr(participant, rule.attr)
+ if doc is None:
+ return 0.0
+ if doc.validation == DocumentValidation.accepted:
+ return 1.0
+ if rule.counts_temporary and doc.validation == DocumentValidation.temporary:
+ return 0.5
+ return 0.0
+
+
+def compute_participant_progress(
+ participant: schemas_raid.RaidParticipant,
+) -> float:
+ """Return the participant's registration progress as a 0-100 percentage.
+
+ Read-only helper so the frontend can show a completion bar; the actual
+ source of truth for whether a participant is allowed to take part remains
+ their `RaidRegistrationStatus`.
+ """
+ rules = _applicable_rules(_context(participant))
+ total = len(_PROFILE_FIELDS) + len(rules)
+ if not total:
+ return 0.0
+ filled_profile = sum(
+ getattr(participant, field) is not None for field in _PROFILE_FIELDS
+ )
+ scored_docs = sum(_score(participant, rule) for rule in rules)
+ return ((filled_profile + scored_docs) / total) * 100
+
+
+def compute_team_progress(team: schemas_raid.RaidTeam) -> float:
+ """Combine the two participants' progress with the team-level metadata."""
+ team_filled = int(team.difficulty is not None) + int(team.meeting_place is not None)
+ team_share = (team_filled / 2) * 10
+ captain = compute_participant_progress(team.captain)
+ second = compute_participant_progress(team.second) if team.second else 0
+ return team_share + (captain + second) * 0.45
+
+
+def count_total_required_documents(participant: schemas_raid.RaidParticipant) -> int:
+ """Number of upload slots required for this participant's profile."""
+ return sum(
+ 1
+ for rule in _applicable_rules(_context(participant))
+ if rule.is_required_document
+ )
+
+
+def count_accepted_documents(participant: schemas_raid.RaidParticipant) -> int:
+ """Number of required uploads that are currently in the `accepted` state."""
+ return sum(
+ 1
+ for rule in _applicable_rules(_context(participant))
+ if rule.is_required_document
+ and (doc := getattr(participant, rule.attr)) is not None
+ and doc.validation == DocumentValidation.accepted
+ )
+
+
+__all__ = [
+ "RaidRegistrationStatus",
+ "check_participant_validation_consistency",
+ "check_volunteer_validation_consistency",
+ "compute_participant_progress",
+ "compute_team_progress",
+ "count_accepted_documents",
+ "count_total_required_documents",
+]
diff --git a/assets/templates/raid_security_file.html b/assets/templates/raid_security_file.html
index 78802a7cd9..6904fa9bd9 100644
--- a/assets/templates/raid_security_file.html
+++ b/assets/templates/raid_security_file.html
@@ -58,7 +58,7 @@
Date de naissance
- {{birthday.strftime("%d/%m/%Y")}}
+ {% if birthday %}{{birthday.strftime("%d/%m/%Y")}}{% endif %}
|
@@ -212,7 +212,7 @@
Date de naissance
|
- {{birthday.strftime("%d/%m/%Y")}}
+ {% if birthday %}{{birthday.strftime("%d/%m/%Y")}}{% endif %}
|
diff --git a/migrations/versions/61-raid_editions_and_state.py b/migrations/versions/61-raid_editions_and_state.py
new file mode 100644
index 0000000000..b0909b924c
--- /dev/null
+++ b/migrations/versions/61-raid_editions_and_state.py
@@ -0,0 +1,694 @@
+"""raid_editions_and_state
+
+Create Date: 2026-04-21 00:00:00.000000
+"""
+
+import contextlib
+import json
+import uuid
+from collections.abc import Sequence
+from enum import Enum
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from pytest_alembic import MigrationContext
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "9e1a4b2d7f10"
+down_revision: str | None = "84ee3296cc58"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+class RaidRegistrationStatus(Enum):
+ draft = "draft"
+ submitted = "submitted"
+ validated = "validated"
+ cancelled = "cancelled"
+
+
+class SituationEnum(Enum):
+ centrale = "centrale"
+ otherSchool = "otherSchool"
+ corporatePartner = "corporatePartner"
+ other = "other"
+
+
+DEFAULT_EDITION_ID = uuid.UUID("fc155c64-46ea-4acd-a941-a31999b5a719")
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ op.create_table(
+ "raid_edition",
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column("year", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=False),
+ sa.Column("start_date", sa.Date(), nullable=True),
+ sa.Column("end_date", sa.Date(), nullable=True),
+ sa.Column("registering_end_date", sa.Date(), nullable=True),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.Column("inscription_enabled", sa.Boolean(), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ # Seed the default active edition; copy start/end date from the
+ # RaidInformation core_data blob if present.
+ raid_start_date = None
+ raid_end_date = None
+ raid_registering_end_date = None
+ raid_info_row = conn.execute(
+ sa.text(
+ "SELECT data FROM core_data WHERE schema = 'RaidInformation' LIMIT 1",
+ ),
+ ).first()
+ if raid_info_row is not None:
+ try:
+ payload = json.loads(raid_info_row[0]) if raid_info_row[0] else {}
+ raid_start_date = payload.get("raid_start_date")
+ raid_end_date = payload.get("raid_end_date")
+ raid_registering_end_date = payload.get("raid_registering_end_date")
+ except (TypeError, ValueError):
+ pass
+
+ conn.execute(
+ sa.text(
+ """
+ INSERT INTO raid_edition (
+ id, year, name, start_date, end_date,
+ registering_end_date, active, inscription_enabled
+ ) VALUES (
+ :id, :year, :name, :start_date, :end_date,
+ :registering_end_date, TRUE, TRUE
+ )
+ """,
+ ).bindparams(
+ id=DEFAULT_EDITION_ID,
+ year=2026,
+ name="Raid",
+ start_date=raid_start_date,
+ end_date=raid_end_date,
+ registering_end_date=raid_registering_end_date,
+ ),
+ )
+
+ status_enum = sa.Enum(
+ RaidRegistrationStatus,
+ name="raidregistrationstatus",
+ )
+ status_enum.create(conn, checkfirst=True)
+ situation_enum = sa.Enum(SituationEnum, name="situation")
+ situation_enum.create(conn, checkfirst=True)
+
+ # ---- raid_participant: rename id -> user_id, add edition_id + status ----
+ # Drop FKs that point at raid_participant.id first.
+ for fk_name, table in (
+ ("raid_team_captain_id_fkey", "raid_team"),
+ ("raid_team_second_id_fkey", "raid_team"),
+ ("raid_participant_checkout_participant_id_fkey", "raid_participant_checkout"),
+ ):
+ with contextlib.suppress(Exception):
+ op.drop_constraint(fk_name, table, type_="foreignkey")
+
+ op.alter_column("raid_participant", "id", new_column_name="user_id")
+ with contextlib.suppress(Exception):
+ op.drop_index("ix_raid_participant_id", table_name="raid_participant")
+ op.create_index(
+ op.f("ix_raid_participant_user_id"),
+ "raid_participant",
+ ["user_id"],
+ unique=False,
+ )
+ op.add_column(
+ "raid_participant",
+ sa.Column("edition_id", sa.Uuid(), nullable=True),
+ )
+ op.add_column(
+ "raid_participant",
+ sa.Column(
+ "status",
+ status_enum,
+ nullable=False,
+ server_default="draft",
+ ),
+ )
+
+ conn.execute(
+ sa.text(
+ "UPDATE raid_participant SET edition_id = :eid WHERE edition_id IS NULL",
+ ).bindparams(eid=DEFAULT_EDITION_ID),
+ )
+ conn.execute(
+ sa.text(
+ """
+ UPDATE raid_participant
+ SET status = 'validated'
+ WHERE payment = true AND attestation_on_honour = true
+ """,
+ ),
+ )
+ conn.execute(
+ sa.text(
+ """
+ UPDATE raid_participant
+ SET status = 'submitted'
+ WHERE attestation_on_honour = true AND status = 'draft'
+ """,
+ ),
+ )
+
+ op.alter_column("raid_participant", "edition_id", nullable=False)
+
+ # Rewrite situation: move " : " suffix into other_school, then enum.
+ op.add_column(
+ "raid_participant",
+ sa.Column("situation_new", situation_enum, nullable=True),
+ )
+ conn.execute(
+ sa.text(
+ """
+ UPDATE raid_participant
+ SET other_school = COALESCE(
+ other_school,
+ SUBSTRING(situation FROM POSITION(' : ' IN situation) + 3)
+ )
+ WHERE situation LIKE 'otherschool : %'
+ """,
+ ),
+ )
+ conn.execute(
+ sa.text(
+ """
+ UPDATE raid_participant SET situation_new = 'otherSchool'
+ WHERE situation LIKE 'otherschool%' OR situation = 'otherSchool'
+ """,
+ ),
+ )
+ for literal in ("centrale", "corporatePartner", "other"):
+ conn.execute(
+ sa.text(
+ "UPDATE raid_participant SET situation_new = CAST(:val AS situation) WHERE situation = :val",
+ ).bindparams(val=literal),
+ )
+ op.drop_column("raid_participant", "situation")
+ op.alter_column("raid_participant", "situation_new", new_column_name="situation")
+
+ # Drop duplicated-of-core-user columns. Copy phone over first if missing.
+ conn.execute(
+ sa.text(
+ """
+ UPDATE core_user
+ SET phone = rp.phone
+ FROM raid_participant rp
+ WHERE core_user.id = rp.user_id
+ AND core_user.phone IS NULL
+ AND rp.phone IS NOT NULL
+ """,
+ ),
+ )
+ conn.execute(
+ sa.text(
+ """
+ UPDATE core_user
+ SET birthday = rp.birthday
+ FROM raid_participant rp
+ WHERE core_user.id = rp.user_id
+ AND core_user.birthday IS NULL
+ AND rp.birthday IS NOT NULL
+ """,
+ ),
+ )
+ op.drop_column("raid_participant", "name")
+ op.drop_column("raid_participant", "firstname")
+ op.drop_column("raid_participant", "email")
+ op.drop_column("raid_participant", "birthday")
+ op.drop_column("raid_participant", "phone")
+
+ # Promote PK to composite + add FKs.
+ with contextlib.suppress(Exception):
+ op.drop_constraint("raid_participant_pkey", "raid_participant", type_="primary")
+ op.create_primary_key(
+ "raid_participant_pkey",
+ "raid_participant",
+ ["user_id", "edition_id"],
+ )
+ op.create_foreign_key(
+ "fk_raid_participant_user",
+ "raid_participant",
+ "core_user",
+ ["user_id"],
+ ["id"],
+ )
+ op.create_foreign_key(
+ "fk_raid_participant_edition",
+ "raid_participant",
+ "raid_edition",
+ ["edition_id"],
+ ["id"],
+ )
+
+ # ---- raid_team: edition_id + composite FKs ----
+ op.add_column(
+ "raid_team",
+ sa.Column("edition_id", sa.Uuid(), nullable=True),
+ )
+ conn.execute(
+ sa.text(
+ "UPDATE raid_team SET edition_id = :eid WHERE edition_id IS NULL",
+ ).bindparams(eid=DEFAULT_EDITION_ID),
+ )
+ op.alter_column("raid_team", "edition_id", nullable=False)
+ op.create_foreign_key(
+ "fk_raid_team_edition",
+ "raid_team",
+ "raid_edition",
+ ["edition_id"],
+ ["id"],
+ )
+ op.create_foreign_key(
+ "fk_raid_team_captain",
+ "raid_team",
+ "raid_participant",
+ ["captain_id", "edition_id"],
+ ["user_id", "edition_id"],
+ )
+ op.create_foreign_key(
+ "fk_raid_team_second",
+ "raid_team",
+ "raid_participant",
+ ["second_id", "edition_id"],
+ ["user_id", "edition_id"],
+ )
+
+ # ---- raid_participant_checkout: rename + composite FK ----
+ op.alter_column(
+ "raid_participant_checkout",
+ "participant_id",
+ new_column_name="participant_user_id",
+ )
+ op.add_column(
+ "raid_participant_checkout",
+ sa.Column("edition_id", sa.Uuid(), nullable=True),
+ )
+ conn.execute(
+ sa.text(
+ "UPDATE raid_participant_checkout SET edition_id = :eid WHERE edition_id IS NULL",
+ ).bindparams(eid=DEFAULT_EDITION_ID),
+ )
+ op.alter_column("raid_participant_checkout", "edition_id", nullable=False)
+ op.create_foreign_key(
+ "fk_raid_participant_checkout_participant",
+ "raid_participant_checkout",
+ "raid_participant",
+ ["participant_user_id", "edition_id"],
+ ["user_id", "edition_id"],
+ )
+
+ # ---- Remaining tables: just edition_id FK ----
+ for table in ("raid_document", "raid_security_file", "raid_invite"):
+ op.add_column(
+ table,
+ sa.Column("edition_id", sa.Uuid(), nullable=True),
+ )
+ conn.execute(
+ sa.text(
+ f"UPDATE {table} SET edition_id = :eid WHERE edition_id IS NULL",
+ ).bindparams(eid=DEFAULT_EDITION_ID),
+ )
+ op.alter_column(table, "edition_id", nullable=False)
+ op.create_foreign_key(
+ f"fk_{table}_edition",
+ table,
+ "raid_edition",
+ ["edition_id"],
+ ["id"],
+ )
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+
+ for table in ("raid_invite", "raid_security_file", "raid_document"):
+ op.drop_constraint(f"fk_{table}_edition", table, type_="foreignkey")
+ op.drop_column(table, "edition_id")
+
+ op.drop_constraint(
+ "fk_raid_participant_checkout_participant",
+ "raid_participant_checkout",
+ type_="foreignkey",
+ )
+ op.drop_column("raid_participant_checkout", "edition_id")
+ op.alter_column(
+ "raid_participant_checkout",
+ "participant_user_id",
+ new_column_name="participant_id",
+ )
+ # The single-column FK is recreated at the end, once raid_participant.id
+ # has been restored as a unique primary key.
+
+ op.drop_constraint("fk_raid_team_captain", "raid_team", type_="foreignkey")
+ op.drop_constraint("fk_raid_team_second", "raid_team", type_="foreignkey")
+ op.drop_constraint("fk_raid_team_edition", "raid_team", type_="foreignkey")
+ op.drop_column("raid_team", "edition_id")
+
+ op.drop_constraint(
+ "fk_raid_participant_edition",
+ "raid_participant",
+ type_="foreignkey",
+ )
+ op.drop_constraint(
+ "fk_raid_participant_user",
+ "raid_participant",
+ type_="foreignkey",
+ )
+ op.drop_constraint("raid_participant_pkey", "raid_participant", type_="primary")
+ op.create_primary_key(
+ "raid_participant_pkey",
+ "raid_participant",
+ ["user_id"],
+ )
+
+ # Restore the dropped identity columns.
+ op.add_column("raid_participant", sa.Column("phone", sa.String(), nullable=True))
+ op.add_column("raid_participant", sa.Column("birthday", sa.Date(), nullable=True))
+ op.add_column("raid_participant", sa.Column("email", sa.String(), nullable=True))
+ op.add_column(
+ "raid_participant",
+ sa.Column("firstname", sa.String(), nullable=True),
+ )
+ op.add_column("raid_participant", sa.Column("name", sa.String(), nullable=True))
+ conn.execute(
+ sa.text(
+ """
+ UPDATE raid_participant
+ SET name = u.name, firstname = u.firstname, email = u.email,
+ birthday = u.birthday, phone = u.phone
+ FROM core_user u
+ WHERE u.id = raid_participant.user_id
+ """,
+ ),
+ )
+
+ op.add_column(
+ "raid_participant",
+ sa.Column("situation_str", sa.String(), nullable=True),
+ )
+ conn.execute(
+ sa.text(
+ """
+ UPDATE raid_participant
+ SET situation_str = CASE
+ WHEN situation = 'otherSchool' AND other_school IS NOT NULL
+ THEN 'otherschool : ' || other_school
+ WHEN situation = 'otherSchool' THEN 'otherschool'
+ ELSE situation::text
+ END
+ """,
+ ),
+ )
+ op.drop_column("raid_participant", "situation")
+ op.alter_column(
+ "raid_participant",
+ "situation_str",
+ new_column_name="situation",
+ )
+
+ op.drop_column("raid_participant", "status")
+ op.drop_column("raid_participant", "edition_id")
+ with contextlib.suppress(Exception):
+ op.drop_index(
+ op.f("ix_raid_participant_user_id"),
+ table_name="raid_participant",
+ )
+ op.alter_column("raid_participant", "user_id", new_column_name="id")
+ op.create_index("ix_raid_participant_id", "raid_participant", ["id"], unique=False)
+ op.create_foreign_key(
+ "raid_team_captain_id_fkey",
+ "raid_team",
+ "raid_participant",
+ ["captain_id"],
+ ["id"],
+ )
+ op.create_foreign_key(
+ "raid_team_second_id_fkey",
+ "raid_team",
+ "raid_participant",
+ ["second_id"],
+ ["id"],
+ )
+ op.create_foreign_key(
+ "raid_participant_checkout_participant_id_fkey",
+ "raid_participant_checkout",
+ "raid_participant",
+ ["participant_id"],
+ ["id"],
+ )
+
+ sa.Enum(name="raidregistrationstatus").drop(conn, checkfirst=True)
+ sa.Enum(name="situation").drop(conn, checkfirst=True)
+ op.drop_table("raid_edition")
+
+
+def pre_test_upgrade(
+ alembic_runner: "MigrationContext",
+ alembic_connection: sa.Connection,
+) -> None:
+ """Seed data that exercises the migration's UPDATEs and FK creations.
+
+ We insert a minimal raid_edition row plus a few raid_participant rows with
+ varied situation/status combos so the migration's data-backfill logic runs
+ against real data.
+
+ Note: raid_edition is CREATED by this migration, so we can't seed it here.
+ The migration itself inserts the default edition (lines 78-97). We only
+ seed the tables that already exist: core_user, raid_participant, raid_team,
+ raid_document, raid_security_file, raid_invite.
+ """
+ # Core users referenced by participants
+ # First add a school for the FK
+ alembic_runner.insert_into(
+ "core_school",
+ {
+ "id": "11111111-1111-1111-1111-111111111111",
+ "name": "Test School",
+ "email_regex": "@example\\.com$",
+ },
+ )
+ alembic_runner.insert_into(
+ "core_school",
+ {
+ "id": "22222222-2222-2222-2222-222222222222",
+ "name": "Test School 2",
+ "email_regex": "@example\\.com$",
+ },
+ )
+
+ alembic_runner.insert_into(
+ "core_user",
+ {
+ "id": "11111111-1111-1111-1111-111111111111",
+ "email": "user1@example.com",
+ "password_hash": "hash",
+ "school_id": "11111111-1111-1111-1111-111111111111",
+ "account_type": "student",
+ "name": "Doe",
+ "firstname": "John",
+ "nickname": "john",
+ "birthday": None,
+ "promo": 2026,
+ "phone": "0102030405",
+ "floor": "Autre",
+ "created_on": None,
+ },
+ )
+ alembic_runner.insert_into(
+ "core_user",
+ {
+ "id": "22222222-2222-2222-2222-222222222222",
+ "email": "user2@example.com",
+ "password_hash": "hash",
+ "school_id": "22222222-2222-2222-2222-222222222222",
+ "account_type": "student",
+ "name": "Smith",
+ "firstname": "Jane",
+ "nickname": "jane",
+ "birthday": None,
+ "promo": 2026,
+ "phone": "0607080910",
+ "floor": "Autre",
+ "created_on": None,
+ },
+ )
+
+ # raid_participant rows BEFORE migration renames `id` -> `user_id`
+ # and adds edition_id + status columns.
+ # Use legacy 'situation' column values that the migration parses.
+ alembic_runner.insert_into(
+ "raid_participant",
+ {
+ "id": "11111111-1111-1111-1111-111111111111",
+ "name": "Doe",
+ "firstname": "John",
+ "email": "user1@example.com",
+ "birthday": "1990-01-01",
+ "phone": "0102030405",
+ "situation": "centrale",
+ "other_school": None,
+ "address": "1 rue Test",
+ "bike_size": "M",
+ "t_shirt_size": "M",
+ "payment": True,
+ "attestation_on_honour": True,
+ "is_minor": False,
+ },
+ )
+ alembic_runner.insert_into(
+ "raid_participant",
+ {
+ "id": "22222222-2222-2222-2222-222222222222",
+ "name": "Smith",
+ "firstname": "Jane",
+ "email": "user2@example.com",
+ "birthday": "1990-01-01",
+ "phone": "0607080910",
+ "situation": "otherschool : CentraleSupélec",
+ "other_school": None,
+ "address": "2 rue Test",
+ "bike_size": "L",
+ "t_shirt_size": "L",
+ "payment": False,
+ "attestation_on_honour": True,
+ "is_minor": False,
+ },
+ )
+
+ # raid_team row referencing the participants
+ alembic_runner.insert_into(
+ "raid_team",
+ {
+ "id": "team-001",
+ "name": "Team Alpha",
+ "difficulty": "discovery",
+ "captain_id": "11111111-1111-1111-1111-111111111111",
+ "second_id": "22222222-2222-2222-2222-222222222222",
+ "number": 1,
+ "meeting_place": "centrale",
+ "file_id": None,
+ },
+ )
+
+ # raid_document, raid_security_file, raid_invite rows
+ alembic_runner.insert_into(
+ "raid_document",
+ {
+ "id": "doc-001",
+ "name": "ID Card",
+ "uploaded_at": "2024-01-01",
+ "type": "idCard",
+ "validation": "accepted",
+ },
+ )
+ alembic_runner.insert_into(
+ "raid_security_file",
+ {
+ "id": "sec-001",
+ "allergy": None,
+ "asthma": False,
+ "intensive_care_unit": False,
+ "intensive_care_unit_when": None,
+ "ongoing_treatment": None,
+ "sicknesses": None,
+ "hospitalization": None,
+ "surgical_operation": None,
+ "trauma": None,
+ "family": None,
+ "emergency_person_firstname": "Contact",
+ "emergency_person_name": "Emergency",
+ "emergency_person_phone": "0102030405",
+ "file_id": None,
+ },
+ )
+ alembic_runner.insert_into(
+ "raid_invite",
+ {
+ "id": "inv-001",
+ "team_id": "team-001",
+ "token": "test-token",
+ },
+ )
+
+
+def test_upgrade(
+ alembic_runner: "MigrationContext",
+ alembic_connection: sa.Connection,
+) -> None:
+ """Verify the migration produced the expected schema + data state."""
+ # raid_edition exists and has our seeded row
+ edition_rows = alembic_connection.execute(
+ sa.text(
+ "SELECT id, year, name, active, inscription_enabled FROM raid_edition",
+ ),
+ ).fetchall()
+ assert len(edition_rows) == 1
+ assert str(edition_rows[0][0]) == str(DEFAULT_EDITION_ID)
+ assert edition_rows[0][1] == 2026
+ assert edition_rows[0][2] == "Raid"
+ assert edition_rows[0][3] is True
+ assert edition_rows[0][4] is True
+
+ # raid_participant: PK is now composite (user_id, edition_id)
+ participant_rows = alembic_connection.execute(
+ sa.text(
+ "SELECT user_id, edition_id, situation, other_school, status "
+ "FROM raid_participant ORDER BY user_id",
+ ),
+ ).fetchall()
+ assert len(participant_rows) == 2
+
+ # User 1: centrale -> status should become 'validated' (payment + attestation)
+ p1 = next(
+ r for r in participant_rows if r[0] == "11111111-1111-1111-1111-111111111111"
+ )
+ assert str(p1[1]) == str(DEFAULT_EDITION_ID)
+ assert p1[2] == "centrale"
+ assert p1[3] is None
+ assert p1[4] == "validated"
+
+ # User 2: otherschool : CentraleSupélec -> other_school populated, status 'submitted'
+ p2 = next(
+ r for r in participant_rows if r[0] == "22222222-2222-2222-2222-222222222222"
+ )
+ assert str(p2[1]) == str(DEFAULT_EDITION_ID)
+ assert p2[2] == "otherSchool"
+ assert p2[3] == "CentraleSupélec"
+ assert p2[4] == "submitted"
+
+ # raid_team got edition_id backfilled and composite FKs created
+ team_rows = alembic_connection.execute(
+ sa.text("SELECT id, edition_id, captain_id, second_id FROM raid_team"),
+ ).fetchall()
+ assert len(team_rows) == 1
+ assert str(team_rows[0][1]) == str(DEFAULT_EDITION_ID)
+ assert team_rows[0][2] == "11111111-1111-1111-1111-111111111111"
+ assert team_rows[0][3] == "22222222-2222-2222-2222-222222222222"
+
+ # raid_participant_checkout: participant_id -> participant_user_id + edition_id
+ alembic_connection.execute(
+ sa.text(
+ "SELECT participant_user_id, edition_id FROM raid_participant_checkout",
+ ),
+ ).fetchall()
+ # No checkout rows were seeded, but the column + FK should exist
+ # (pytest-alembic will error if the FK creation fails)
+
+ # raid_document, raid_security_file, raid_invite got edition_id + FKs
+ for table in ("raid_document", "raid_security_file", "raid_invite"):
+ rows = alembic_connection.execute(
+ sa.text(f"SELECT edition_id FROM {table}"),
+ ).fetchall()
+ assert len(rows) >= 1
+ assert all(str(r[0]) == str(DEFAULT_EDITION_ID) for r in rows)
diff --git a/migrations/versions/62-raid_volunteers.py b/migrations/versions/62-raid_volunteers.py
new file mode 100644
index 0000000000..d7071f602e
--- /dev/null
+++ b/migrations/versions/62-raid_volunteers.py
@@ -0,0 +1,94 @@
+"""raid_volunteers
+
+Create Date: 2026-04-21 00:05:00.000000
+"""
+
+from collections.abc import Sequence
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from pytest_alembic import MigrationContext
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+from app.types.sqlalchemy import TZDateTime
+
+# revision identifiers, used by Alembic.
+revision: str = "b23c5f9d8a42"
+down_revision: str | None = "9e1a4b2d7f10"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ # The size enum was created in migration 19-raid_registering; reuse it.
+ size = postgresql.ENUM(
+ "XS",
+ "S",
+ "M",
+ "L",
+ "XL",
+ name="size",
+ create_type=False,
+ )
+ op.create_table(
+ "raid_volunteer",
+ sa.Column("user_id", sa.String(), nullable=False),
+ sa.Column("edition_id", sa.Uuid(), nullable=False),
+ sa.Column("created_at", TZDateTime(), nullable=False),
+ sa.Column("validated", sa.Boolean(), nullable=False),
+ sa.Column("cancelled", sa.Boolean(), nullable=False),
+ sa.Column("t_shirt_size", size, nullable=True),
+ sa.Column("diet", sa.String(), nullable=True),
+ sa.Column("allergy", sa.String(), nullable=True),
+ sa.Column("emergency_person_name", sa.String(), nullable=True),
+ sa.Column("emergency_person_phone", sa.String(), nullable=True),
+ sa.Column(
+ "has_car",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ sa.Column("car_seats", sa.Integer(), nullable=True),
+ sa.Column(
+ "is_special_driver",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ sa.Column(
+ "is_utility_vehicle_driver",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ sa.Column(
+ "is_parcours_helper",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]),
+ sa.ForeignKeyConstraint(["edition_id"], ["raid_edition.id"]),
+ sa.PrimaryKeyConstraint("user_id", "edition_id"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("raid_volunteer")
+
+
+def pre_test_upgrade(
+ alembic_runner: "MigrationContext",
+ alembic_connection: sa.Connection,
+) -> None:
+ pass
+
+
+def test_upgrade(
+ alembic_runner: "MigrationContext",
+ alembic_connection: sa.Connection,
+) -> None:
+ pass
diff --git a/migrations/versions/63_add_unique_constraint_second_id_raid_.py b/migrations/versions/63_add_unique_constraint_second_id_raid_.py
new file mode 100644
index 0000000000..5bcd222dcb
--- /dev/null
+++ b/migrations/versions/63_add_unique_constraint_second_id_raid_.py
@@ -0,0 +1,62 @@
+"""add_unique_constraint_second_id_raid_team
+
+Create Date: 2026-08-07 13:00:39.510717
+"""
+
+from collections.abc import Sequence
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from pytest_alembic import MigrationContext
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "63"
+down_revision: str | None = "b23c5f9d8a42"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ # Add unique constraint on second_id + edition_id to prevent race condition
+ # when two users try to join the same team simultaneously
+ op.create_unique_constraint(
+ "uq_raid_team_second_id_edition_id",
+ "raid_team",
+ ["second_id", "edition_id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_constraint(
+ "uq_raid_team_second_id_edition_id",
+ "raid_team",
+ type_="unique",
+ )
+
+
+def pre_test_upgrade(
+ alembic_runner: "MigrationContext",
+ alembic_connection: sa.Connection,
+) -> None:
+ # No pre-test data needed - constraint is new
+ pass
+
+
+def test_upgrade(
+ alembic_runner: "MigrationContext",
+ alembic_connection: sa.Connection,
+) -> None:
+ # Verify the unique constraint was created
+ result = alembic_connection.execute(
+ sa.text(
+ """
+ SELECT conname FROM pg_constraint
+ WHERE conname = 'uq_raid_team_second_id_edition_id'
+ AND contype = 'u'
+ """,
+ ),
+ ).fetchall()
+ assert len(result) == 1
diff --git a/requirements-dev.txt b/requirements-dev.txt
index f9a4c596ab..1f8667004d 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -11,6 +11,5 @@ pytest-mock==3.14.1
pytest==9.0.1
ruff==0.15.10
types-Authlib==1.5.0.20250516
-types-fpdf2==2.8.3.20250516
types-psutil==7.0.0.20250601
types-redis==4.6.0.20241004
diff --git a/requirements.txt b/requirements.txt
index 7eb5aca243..b9f3f918ed 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -26,7 +26,6 @@ pydantic-settings==2.3.4
pydantic==2.12.5
pyjwt[crypto]==2.10.1 # generate and verify the JWT tokens, imported as `jwt`
PyMuPDF==1.26.7 # PDF processing, imported as `fitz`
-pypdf==6.4.0
python-multipart==0.0.18 # a form data parser, as oauth flow requires form-data parameters
redis==5.0.8
sqlalchemy-utils == 0.41.2
diff --git a/tests/modules/raid/__init__.py b/tests/modules/raid/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/modules/raid/test_pdf_generation.py b/tests/modules/raid/test_pdf_generation.py
new file mode 100644
index 0000000000..4ca4bddcfd
--- /dev/null
+++ b/tests/modules/raid/test_pdf_generation.py
@@ -0,0 +1,237 @@
+"""Tests for PDF generation to ensure filenames are UUIDs, not team names."""
+import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from uuid import uuid4
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.modules.raid import coredata_raid, schemas_raid
+from app.modules.raid.raid_type import Difficulty, Situation, Size
+from app.modules.raid.utils.utils_raid import generate_recap_file_pdf, generate_security_file_pdf, get_all_team_files_zip, get_all_security_files_zip
+
+
+# --- Helper functions -----------------------------------------------------
+
+
+def _create_mock_team(team_id: str = None, team_name: str = "Test Team"):
+ """Create a mock RaidTeam for testing."""
+ if team_id is None:
+ team_id = str(uuid4())
+ team = Mock(spec=schemas_raid.RaidTeam)
+ team.id = team_id
+ team.name = team_name
+ team.difficulty = Difficulty.discovery
+ team.meeting_place = None
+ team.number = 1
+ team.captain = _create_mock_participant()
+ team.second = None
+ return team
+
+
+def _create_mock_participant(user_id: str = None):
+ """Create a mock RaidParticipant for testing."""
+ if user_id is None:
+ user_id = str(uuid4())
+ participant = MagicMock(spec=schemas_raid.RaidParticipant)
+ participant.user_id = user_id
+ participant.situation = Situation.centrale
+ participant.is_minor = False
+ participant.student_card_id = None
+ participant.id_card = None
+ participant.medical_certificate = None
+ participant.raid_rules = None
+ participant.parent_authorization = None
+ participant.security_file = None
+ participant.student_card = None
+ participant.medical_certificate_id = None
+ participant.id_card_id = None
+ participant.raid_rules_id = None
+ participant.parent_authorization_id = None
+ participant.attestation_on_honour = False
+ participant.payment = False
+ participant.t_shirt_payment = False
+ participant.t_shirt_size = Size.M
+ participant.diet = None
+ participant.address = None
+ participant.other_school = None
+ participant.company = None
+ participant.bike_size = Size.M
+ participant.status = "draft"
+ participant.user = MagicMock()
+ participant.user.name = "Doe"
+ participant.user.firstname = "John"
+ participant.user.email = "john@example.com"
+ participant.user.phone = "+33123456789"
+ participant.user.birthday = datetime.date(1990, 1, 1)
+ return participant
+
+
+def _create_mock_information():
+ """Create a mock RaidInformation for testing."""
+ info = MagicMock(spec=coredata_raid.RaidInformation)
+ info.president = None
+ info.rescue = None
+ info.security_responsible = None
+ info.volunteer_responsible = None
+ return info
+
+
+def _create_mock_team(team_id: str = None, team_name: str = "Test Team"):
+ """Create a mock RaidTeam for testing."""
+ if team_id is None:
+ team_id = str(uuid4())
+ team = MagicMock(spec=schemas_raid.RaidTeam)
+ team.id = team_id
+ team.name = team_name
+ team.difficulty = Difficulty.discovery
+ team.meeting_place = None
+ team.number = 1
+ team.captain = _create_mock_participant()
+ team.second = None
+ return team
+
+
+class TestPDFGenerationFilename:
+ """Tests to ensure PDF filenames are UUIDs, not team names."""
+
+ @pytest.mark.asyncio
+ async def test_generate_security_file_pdf_uses_user_id_as_filename(self):
+ """Test that security file PDF uses participant.user_id as filename (UUID)."""
+ participant = _create_mock_participant()
+ information = _create_mock_information()
+
+ with patch("app.modules.raid.utils.utils_raid.generate_pdf_from_template", new=AsyncMock()) as mock_generate:
+ result = await generate_security_file_pdf(participant, information)
+
+ # The filename should be the participant's user_id (a UUID)
+ mock_generate.assert_called_once()
+ call_kwargs = mock_generate.call_args.kwargs
+ assert call_kwargs["filename"] == participant.user_id
+ # The filename should be a UUID, not a team name
+ assert call_kwargs["filename"] != "Équipe de xxxx"
+ assert call_kwargs["filename"] != participant.user.name
+
+ @pytest.mark.asyncio
+ async def test_generate_recap_file_pdf_uses_team_id_as_filename(self):
+ """Test that recap file PDF uses team.id as filename (UUID), not team.name."""
+ team = _create_mock_team(team_name="Équipe de Test")
+ team_id = team.id
+
+ with patch("app.modules.raid.utils.utils_raid.generate_pdf_from_template", new=AsyncMock()) as mock_generate:
+ result = await generate_recap_file_pdf(team)
+
+ # The filename should be the team.id (a UUID)
+ mock_generate.assert_called_once()
+ call_kwargs = mock_generate.call_args.kwargs
+ assert call_kwargs["filename"] == team_id
+ # The filename should NOT be the team name
+ assert call_kwargs["filename"] != team.name
+ assert call_kwargs["filename"] != "Équipe de Test"
+
+ @pytest.mark.asyncio
+ async def test_get_all_team_files_zip_uses_team_id_for_pdf(self):
+ """Test that get_all_team_files_zip uses team.id as filename for PDF generation."""
+ team = _create_mock_team(team_name="Équipe de Test")
+ db = AsyncMock(spec=AsyncSession)
+ information = _create_mock_information()
+ edition_id = uuid4()
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid.get_all_teams", new=AsyncMock(return_value=[team])):
+ with patch("app.modules.raid.utils.utils_raid.generate_recap_file_pdf", new=AsyncMock(return_value=team.id)) as mock_generate:
+ with patch("app.modules.raid.utils.utils_raid.get_file_path_from_data", new=AsyncMock(return_value=MagicMock())):
+ with patch("zipfile.ZipFile", new=MagicMock()):
+ await get_all_team_files_zip(db, information, edition_id)
+
+ # The PDF should be generated with team.id (UUID), not team.name
+ mock_generate.assert_called_once_with(team)
+ # Verify the result is team.id (UUID)
+ assert mock_generate.return_value == team.id
+
+ @pytest.mark.asyncio
+ async def test_get_all_security_files_zip_uses_user_id_for_pdf(self):
+ """Test that get_all_security_files_zip uses participant.user_id as filename."""
+ team = _create_mock_team(team_name="Équipe de Test")
+ db = AsyncMock(spec=AsyncSession)
+ information = _create_mock_information()
+ edition_id = uuid4()
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid.get_all_teams", new=AsyncMock(return_value=[team])):
+ with patch("app.modules.raid.utils.utils_raid.generate_security_file_pdf", new=AsyncMock(return_value=team.captain.user_id)) as mock_generate:
+ with patch("app.modules.raid.utils.utils_raid.get_file_path_from_data", new=AsyncMock(return_value=MagicMock())):
+ with patch("zipfile.ZipFile", new=MagicMock()):
+ await get_all_security_files_zip(db, information, edition_id)
+
+ # The PDF should be generated with participant.user_id (UUID), not team name
+ mock_generate.assert_called()
+ # Verify the result is participant.user_id (UUID)
+ assert mock_generate.return_value == team.captain.user_id
+
+
+class TestPDFGenerationOldCodePattern:
+ """Tests that verify the OLD code pattern (team name as filename) is NOT used."""
+
+ @pytest.mark.asyncio
+ async def test_old_pdf_writer_pattern_not_used(self):
+ """Verify the old pattern using team.name as filename is not present.
+
+ The old code (before weasyprint migration) used:
+ - file_name = f"{team.number}_{team.name}_{captain.name}_{captain.firstname}.pdf"
+ - pdf_writer.write_team(team) which used team.name in filename
+
+ Current code should use team.id (UUID) as filename.
+ """
+ team = _create_mock_team(team_name="Équipe de Test")
+
+ with patch("app.modules.raid.utils.utils_raid.generate_pdf_from_template", new=AsyncMock()) as mock_generate:
+ await generate_recap_file_pdf(team)
+
+ call_kwargs = mock_generate.call_args.kwargs
+ filename = call_kwargs["filename"]
+
+ # Should be UUID, not team name with special characters
+ assert filename == team.id
+ assert "Équipe" not in filename
+ assert " " not in filename # UUIDs don't have spaces
+ assert "_" not in filename or len(filename) == 36 # UUID format
+
+
+class TestSaveBytesAsDataFilenameValidation:
+ """Tests for save_bytes_as_data filename validation."""
+
+ @pytest.mark.asyncio
+ async def test_save_bytes_as_data_rejects_non_uuid_filename(self):
+ """Test that save_bytes_as_data rejects non-UUID filenames.
+
+ This is the protection that would catch the old pattern
+ if it somehow made it through.
+ """
+ from app.utils.tools import save_bytes_as_data, FileNameIsNotAnUUIDError
+
+ # Try to save with a team-name-like filename (should fail)
+ with pytest.raises(FileNameIsNotAnUUIDError):
+ await save_bytes_as_data(
+ file_bytes=b"test",
+ directory="test",
+ filename="Équipe de Test", # Not a UUID
+ extension="pdf",
+ )
+
+ # Try with a filename that has spaces (should fail)
+ with pytest.raises(FileNameIsNotAnUUIDError):
+ await save_bytes_as_data(
+ file_bytes=b"test",
+ directory="test",
+ filename="team name with spaces", # Not a UUID
+ extension="pdf",
+ )
+
+ # Valid UUID should work (if directory exists)
+ import tempfile
+ import os
+ with tempfile.TemporaryDirectory() as tmpdir:
+ # Temporarily change the data directory
+ import app.utils.tools as tools
+ original_data_path = tools.__file__
+ # We can't easily change the data path, so just test the validation
+ pass
\ No newline at end of file
diff --git a/tests/modules/raid/test_schemas_raid.py b/tests/modules/raid/test_schemas_raid.py
new file mode 100644
index 0000000000..5d44238a37
--- /dev/null
+++ b/tests/modules/raid/test_schemas_raid.py
@@ -0,0 +1,218 @@
+"""Unit tests for app/modules/raid/schemas_raid.py.
+
+Focus on the Pydantic validators that encode new business rules:
+- Legacy lowercase `otherschool` coercion on `situation`.
+- `situation=otherSchool` requires `other_school` to be set.
+- Switching back to `centrale` clears `other_school`.
+- Pydantic-level required fields on the edition / volunteer schemas.
+"""
+
+from datetime import date
+from uuid import uuid4
+
+import pytest
+from pydantic import ValidationError
+
+from app.core.groups.groups_type import AccountType
+from app.core.users.schemas_users import CoreUser
+from app.modules.raid import schemas_raid
+from app.modules.raid.raid_type import (
+ Difficulty,
+ MeetingPlace,
+ RaidRegistrationStatus,
+ Situation,
+ Size,
+)
+
+# -- RaidParticipantUpdate: situation validators ---------------------------
+
+
+def test_participant_update_accepts_enum_values() -> None:
+ u = schemas_raid.RaidParticipantUpdate(situation=Situation.centrale)
+ assert u.situation == Situation.centrale
+
+
+def test_participant_update_coerces_legacy_lowercase_otherschool() -> None:
+ u = schemas_raid.RaidParticipantUpdate(
+ situation="otherschool",
+ other_school="ECP",
+ )
+ assert u.situation == Situation.otherSchool
+ assert u.other_school == "ECP"
+
+
+def test_participant_update_coerces_legacy_suffix_otherschool() -> None:
+ u = schemas_raid.RaidParticipantUpdate(
+ situation="otherschool : MyPrepa",
+ other_school="MyPrepa",
+ )
+ assert u.situation == Situation.otherSchool
+
+
+def test_participant_update_coerces_camelcase_string() -> None:
+ u = schemas_raid.RaidParticipantUpdate(
+ situation="otherSchool",
+ other_school="Other",
+ )
+ assert u.situation == Situation.otherSchool
+
+
+def test_participant_update_rejects_otherschool_without_school_name() -> None:
+ with pytest.raises(ValidationError):
+ schemas_raid.RaidParticipantUpdate(situation=Situation.otherSchool)
+
+
+def test_participant_update_clears_other_school_when_centrale() -> None:
+ u = schemas_raid.RaidParticipantUpdate(
+ situation=Situation.centrale,
+ other_school="leftover value",
+ )
+ assert u.other_school is None
+
+
+def test_participant_update_allows_empty_body() -> None:
+ # A PATCH with no fields should validate (no required fields).
+ schemas_raid.RaidParticipantUpdate()
+
+
+def test_participant_update_preserves_other_school_when_other() -> None:
+ u = schemas_raid.RaidParticipantUpdate(
+ situation=Situation.other,
+ other_school="kept",
+ )
+ assert u.other_school == "kept"
+
+
+# -- RaidEdition(Base|Edit) --------------------------------------------------
+
+
+def test_edition_base_defaults() -> None:
+ e = schemas_raid.RaidEditionBase(name="Raid 2026", year=2026)
+ assert e.active is False
+ assert e.inscription_enabled is False
+ assert e.start_date is None
+
+
+def test_edition_base_requires_name_and_year() -> None:
+ with pytest.raises(ValidationError):
+ schemas_raid.RaidEditionBase(year=2026) # type: ignore[call-arg]
+ with pytest.raises(ValidationError):
+ schemas_raid.RaidEditionBase(name="x") # type: ignore[call-arg]
+
+
+def test_edition_edit_allows_partial_update() -> None:
+ e = schemas_raid.RaidEditionEdit(active=True)
+ assert e.active is True
+ assert e.name is None
+
+
+def test_edition_full_from_attributes() -> None:
+ # Construct an edition with a UUID id — mirrors the ORM shape the API
+ # returns to the frontend.
+ eid = uuid4()
+ e = schemas_raid.RaidEdition(
+ id=eid,
+ name="Raid",
+ year=2026,
+ start_date=date(2026, 5, 1),
+ end_date=date(2026, 5, 3),
+ registering_end_date=date(2026, 4, 20),
+ active=True,
+ inscription_enabled=True,
+ )
+ assert e.id == eid
+ assert e.active is True
+
+
+# -- RaidVolunteerBase / Edit ----------------------------------------------
+
+
+def test_volunteer_base_accepts_empty() -> None:
+ schemas_raid.RaidVolunteerBase()
+
+
+def test_volunteer_base_accepts_full() -> None:
+ v = schemas_raid.RaidVolunteerBase(
+ t_shirt_size=Size.M,
+ diet="veggie",
+ allergy=None,
+ emergency_person_name="Jane",
+ emergency_person_phone="06",
+ )
+ assert v.t_shirt_size == Size.M
+
+
+def test_volunteer_edit_is_full_partial() -> None:
+ v = schemas_raid.RaidVolunteerEdit(diet="noodles")
+ assert v.diet == "noodles"
+ assert v.t_shirt_size is None
+
+
+# -- RaidTeamPreview + RaidTeam computed validation_progress --------------
+
+
+def test_team_preview_progress_with_no_participants() -> None:
+ # Using enum values for difficulty/meeting_place to ensure validator passes.
+ preview = schemas_raid.RaidTeamPreview(
+ id="tid",
+ edition_id=uuid4(),
+ name="T",
+ number=None,
+ captain_id="u1",
+ captain=schemas_raid.RaidParticipantPreview(
+ user_id="u1",
+ edition_id=uuid4(),
+ status=RaidRegistrationStatus.draft,
+ payment=False,
+ t_shirt_payment=False,
+ user=_dummy_core_user("u1"),
+ ),
+ second=None,
+ difficulty=None,
+ meeting_place=None,
+ )
+ # Preview has neither difficulty nor meeting place, and the preview
+ # captain isn't full RaidParticipant → contributes 0 progress.
+ assert preview.validation_progress == 0
+
+
+def test_team_preview_progress_with_filled_meta_only() -> None:
+ preview = schemas_raid.RaidTeamPreview(
+ id="tid",
+ edition_id=uuid4(),
+ name="T",
+ number=42,
+ captain_id="u1",
+ captain=schemas_raid.RaidParticipantPreview(
+ user_id="u1",
+ edition_id=uuid4(),
+ status=RaidRegistrationStatus.draft,
+ payment=False,
+ t_shirt_payment=False,
+ user=_dummy_core_user("u1"),
+ ),
+ second=None,
+ difficulty=Difficulty.sports,
+ meeting_place=MeetingPlace.centrale,
+ )
+ assert preview.validation_progress == 10 # (2/2)*10 + 0 captain/second
+
+
+# Shared helper --------------------------------------------------------------
+
+
+def _dummy_core_user(uid: str):
+ return CoreUser(
+ id=uid,
+ email=f"{uid}@example.com",
+ account_type=AccountType.student,
+ school_id=uuid4(),
+ name="Doe",
+ firstname="John",
+ nickname=None,
+ birthday=None,
+ promo=None,
+ floor=None,
+ phone=None,
+ created_on=None,
+ )
diff --git a/tests/modules/raid/test_security_file_fk.py b/tests/modules/raid/test_security_file_fk.py
new file mode 100644
index 0000000000..4d19f76fd6
--- /dev/null
+++ b/tests/modules/raid/test_security_file_fk.py
@@ -0,0 +1,312 @@
+import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.payment import schemas_payment
+from app.modules.raid import coredata_raid, cruds_raid, schemas_raid
+from app.modules.raid.raid_type import (
+ Difficulty,
+ DocumentValidation,
+ Situation,
+ Size,
+)
+from app.modules.raid.endpoints_raid import set_security_file
+from app.modules.raid.models_raid import RaidParticipant, SecurityFile
+
+
+# --- Helper functions -----------------------------------------------------
+
+
+def _create_mock_db():
+ """Create a mock async database session."""
+ db = AsyncMock(spec=AsyncSession)
+ # Mock the execute method to return a proper result
+ mock_result = MagicMock()
+ mock_result.scalars.return_value.first.return_value = None
+ mock_result.scalars.return_value.all.return_value = []
+ mock_result.first.return_value = None
+ db.execute = AsyncMock(return_value=mock_result)
+ db.flush = AsyncMock()
+ db.commit = AsyncMock()
+ return db
+
+
+def _create_mock_user(user_id: str = "user_123"):
+ """Create a mock CoreUser for testing."""
+ user = Mock()
+ user.id = user_id
+ user.name = "Test"
+ user.firstname = "User"
+ user.email = "test@example.com"
+ user.phone = "+33123456789"
+ user.birthday = datetime.date(1990, 1, 1)
+ user.groups = []
+ user.account_type = None
+ return user
+
+
+def _create_mock_edition():
+ """Create a mock RaidEdition for testing."""
+ edition = Mock(spec=schemas_raid.RaidEdition)
+ edition.id = uuid4()
+ edition.name = "Raid 2024"
+ edition.year = 2024
+ edition.active = True
+ edition.inscription_enabled = True
+ return edition
+
+
+def _create_mock_team(team_id: str = "team_123"):
+ """Create a mock RaidTeam for testing."""
+ team = Mock(spec=schemas_raid.RaidTeam)
+ team.id = team_id
+ return team
+
+
+def _create_mock_participant(security_file_id: str | None = "sec_file_123", user_id: str = "user_123", edition_id=None):
+ """Create a mock RaidParticipant for testing."""
+ if edition_id is None:
+ edition_id = uuid4()
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.user_id = user_id
+ participant.edition_id = edition_id
+ participant.security_file_id = security_file_id
+ participant.status = "draft"
+ participant.user = _create_mock_user(user_id)
+ return participant
+
+
+def _create_mock_security_file_base():
+ """Create a mock SecurityFileBase for testing."""
+ security_file = schemas_raid.SecurityFileBase(
+ allergy=None,
+ asthma=False,
+ intensive_care_unit=None,
+ intensive_care_unit_when=None,
+ ongoing_treatment=None,
+ sicknesses=None,
+ hospitalization=None,
+ surgical_operation=None,
+ trauma=None,
+ family=None,
+ emergency_person_firstname="Jane",
+ emergency_person_name="Doe",
+ emergency_person_phone="0600000000",
+ file_id=None,
+ )
+ return security_file
+
+
+# --- Tests for security file FK violation ----------------------------------
+
+
+class TestSecurityFileFKViolation:
+ """Tests to reproduce the FK violation when updating security file."""
+
+ @pytest.mark.asyncio
+ async def test_set_security_file_update_existing_success(self):
+ """Test updating an existing security file works without FK violation."""
+ db = _create_mock_db()
+ user = _create_mock_user()
+ edition = _create_mock_edition()
+ team = _create_mock_team()
+ participant = _create_mock_participant(security_file_id="existing_sec_file_id")
+ security_file_base = _create_mock_security_file_base()
+
+ # Mock the permission check
+ with patch("app.modules.raid.endpoints_raid.has_user_permission", new=AsyncMock(return_value=False)):
+ # Mock get_participant_or_404
+ with patch("app.modules.raid.endpoints_raid.get_participant_or_404", new=AsyncMock(return_value=participant)):
+ # Mock get_team_by_participant_id for both user and target
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.get_team_by_participant_id", new=AsyncMock(return_value=team)):
+ # Mock cruds_raid.update_security_file
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.update_security_file", new=AsyncMock()) as mock_update:
+ # Mock cruds_raid.get_security_file_by_security_id
+ mock_security_file = Mock(spec=schemas_raid.SecurityFile)
+ mock_security_file.id = "existing_sec_file_id"
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.get_security_file_by_security_id", new=AsyncMock(return_value=mock_security_file)):
+ result = await set_security_file(
+ security_file=security_file_base,
+ participant_id="user_123",
+ db=db,
+ user=user,
+ edition=edition,
+ )
+
+ # Verify update_security_file was called with correct ID
+ mock_update.assert_called_once_with(
+ security_file_id="existing_sec_file_id",
+ security_file=security_file_base,
+ db=db,
+ )
+ assert result is mock_security_file
+
+ @pytest.mark.asyncio
+ async def test_set_security_file_create_new_when_none_exists(self):
+ """Test creating a new security file when participant has none."""
+ db = _create_mock_db()
+ user = _create_mock_user()
+ edition = _create_mock_edition()
+ team = _create_mock_team()
+ participant = _create_mock_participant(security_file_id=None)
+ security_file_base = _create_mock_security_file_base()
+
+ with patch("app.modules.raid.endpoints_raid.has_user_permission", new=AsyncMock(return_value=False)):
+ with patch("app.modules.raid.endpoints_raid.get_participant_or_404", new=AsyncMock(return_value=participant)):
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.get_team_by_participant_id", new=AsyncMock(return_value=team)):
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.add_security_file", new=AsyncMock()) as mock_add:
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.assign_security_file", new=AsyncMock()) as mock_assign:
+ mock_security_file = Mock(spec=schemas_raid.SecurityFile)
+ mock_security_file.id = "new_sec_file_id"
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.get_security_file_by_security_id", new=AsyncMock(return_value=mock_security_file)):
+ with patch("uuid.uuid4", return_value="new_sec_file_id"):
+ result = await set_security_file(
+ security_file=security_file_base,
+ participant_id="user_123",
+ db=db,
+ user=user,
+ edition=edition,
+ )
+
+ # Verify add_security_file was called
+ mock_add.assert_called_once()
+ # Verify assign_security_file was called
+ mock_assign.assert_called_once_with(
+ "user_123",
+ edition.id,
+ "new_sec_file_id",
+ db,
+ )
+ assert result is mock_security_file
+
+ @pytest.mark.asyncio
+ async def test_set_security_file_update_preserves_participant_link(self):
+ """Test that updating security file preserves the participant -> security_file link.
+
+ This is the critical test - the FK violation happens when the update
+ somehow breaks the link between participant.security_file_id and
+ security_file.id.
+ """
+ db = _create_mock_db()
+ user = _create_mock_user()
+ edition = _create_mock_edition()
+ team = _create_mock_team()
+ security_file_id = "sec_file_123"
+ participant = _create_mock_participant(security_file_id=security_file_id)
+ security_file_base = _create_mock_security_file_base()
+
+ with patch("app.modules.raid.endpoints_raid.has_user_permission", new=AsyncMock(return_value=False)):
+ with patch("app.modules.raid.endpoints_raid.get_participant_or_404", new=AsyncMock(return_value=participant)):
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.get_team_by_participant_id", new=AsyncMock(return_value=team)):
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.update_security_file", new=AsyncMock()) as mock_update:
+ mock_security_file = Mock(spec=schemas_raid.SecurityFile)
+ mock_security_file.id = security_file_id
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.get_security_file_by_security_id", new=AsyncMock(return_value=mock_security_file)):
+ result = await set_security_file(
+ security_file=security_file_base,
+ participant_id="user_123",
+ db=db,
+ user=user,
+ edition=edition,
+ )
+
+ # The update should NOT change the security_file_id
+ # It should only update the fields in SecurityFileBase
+ mock_update.assert_called_once()
+ call_args = mock_update.call_args
+ assert call_args.kwargs["security_file_id"] == security_file_id
+ assert call_args.kwargs["security_file"] == security_file_base
+ assert call_args.kwargs["db"] == db
+
+ @pytest.mark.asyncio
+ async def test_update_security_file_does_not_change_id(self):
+ """Test that update_security_file doesn't try to change the primary key.
+
+ This is the potential bug - if update_security_file somehow includes
+ the 'id' field in the update values, it would violate the FK because
+ the participant still references the old ID.
+ """
+ security_file_id = "sec_file_123"
+
+ # Create a SecurityFileBase with all fields
+ security_file_base = schemas_raid.SecurityFileBase(
+ allergy="pollen",
+ asthma=True,
+ intensive_care_unit=False,
+ intensive_care_unit_when=None,
+ ongoing_treatment="medication",
+ sicknesses="asthma",
+ hospitalization="none",
+ surgical_operation="appendectomy",
+ trauma="broken arm",
+ family="heart disease",
+ emergency_person_firstname="John",
+ emergency_person_name="Smith",
+ emergency_person_phone="0612345678",
+ file_id="file_123",
+ )
+
+ # Check what model_dump returns
+ dump = security_file_base.model_dump(exclude_none=True)
+ assert "id" not in dump, "SecurityFileBase should not have 'id' field"
+ assert "validation" not in dump, "SecurityFileBase should not have 'validation' field"
+ # The dump should only contain the fields that are set
+ assert dump["allergy"] == "pollen"
+ assert dump["asthma"] is True
+ assert dump["emergency_person_firstname"] == "John"
+
+ # This test verifies the schema doesn't include id/validation
+ # The actual cruds_raid.update_security_file uses this dump
+ # So if the schema is correct, the update won't include id/validation
+
+
+# --- Additional test for the FK violation scenario ---
+
+
+class TestSecurityFileFKEdgeCases:
+ """Edge cases that might trigger the FK violation."""
+
+ @pytest.mark.asyncio
+ async def test_multiple_participants_same_security_file(self):
+ """Test scenario where multiple participants might reference same security file.
+
+ This shouldn't normally happen (one-to-one), but if it does, updating
+ the security file could affect multiple participants.
+ """
+ # This is more of a data integrity test - in normal operation,
+ # each participant should have their own security file
+ pass
+
+ @pytest.mark.asyncio
+ async def test_security_file_id_mismatch(self):
+ """Test when participant.security_file_id doesn't match actual security file.
+
+ This could happen if:
+ 1. A security file was deleted but participant still references it
+ 2. A race condition during creation
+ """
+ db = _create_mock_db()
+ user = _create_mock_user()
+ edition = _create_mock_edition()
+ team = _create_mock_team()
+ participant = _create_mock_participant(security_file_id="non_existent_sec_file")
+ security_file_base = _create_mock_security_file_base()
+
+ with patch("app.modules.raid.endpoints_raid.has_user_permission", new=AsyncMock(return_value=False)):
+ with patch("app.modules.raid.endpoints_raid.get_participant_or_404", new=AsyncMock(return_value=participant)):
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.get_team_by_participant_id", new=AsyncMock(return_value=team)):
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.update_security_file", new=AsyncMock()) as mock_update:
+ mock_update.side_effect = Exception("FK violation: security file not found")
+ with patch("app.modules.raid.endpoints_raid.cruds_raid.get_security_file_by_security_id", new=AsyncMock(return_value=None)):
+ with pytest.raises(Exception, match="FK violation"):
+ await set_security_file(
+ security_file=security_file_base,
+ participant_id="user_123",
+ db=db,
+ user=user,
+ edition=edition,
+ )
\ No newline at end of file
diff --git a/tests/modules/raid/test_utils_raid.py b/tests/modules/raid/test_utils_raid.py
new file mode 100644
index 0000000000..1a47713aa1
--- /dev/null
+++ b/tests/modules/raid/test_utils_raid.py
@@ -0,0 +1,276 @@
+"""Unit tests for app/modules/raid/utils/utils_raid.py.
+
+Covers:
+- `will_birthday_be_minor_on` edge cases (no birthday, no raid date, on the
+ exact cutoff day).
+- `calculate_raid_payment` with the new Situation enum (no more `split(' : ')`).
+- `set_team_number` via mocks — the CRUD-side `get_max_team_number_by_difficulty`
+ now takes edition_id.
+"""
+
+import datetime
+import uuid
+from typing import Any
+from unittest.mock import AsyncMock, Mock
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+from pytest_mock import MockerFixture
+
+from app.modules.raid import coredata_raid
+from app.modules.raid.models_raid import RaidParticipant, RaidTeam
+from app.modules.raid.raid_type import Difficulty, Situation, Size
+from app.modules.raid.utils.utils_raid import (
+ calculate_raid_payment,
+ set_team_number,
+ will_birthday_be_minor_on,
+)
+
+# -- will_birthday_be_minor_on ---------------------------------------------
+
+
+def test_minor_when_birthday_unknown() -> None:
+ assert will_birthday_be_minor_on(None, datetime.date(2026, 5, 1)) is True
+
+
+def test_minor_without_raid_date_uses_next_year_jan_1() -> None:
+ # A 16-year-old on today's year is still minor on Jan 1 next year.
+ today = datetime.datetime.now(tz=datetime.UTC).date()
+ assert (
+ will_birthday_be_minor_on(
+ datetime.date(today.year - 16, today.month, today.day),
+ None,
+ )
+ is True
+ )
+
+
+def test_not_minor_if_birthday_18_years_before_raid() -> None:
+ raid_date = datetime.date(2026, 5, 1)
+ eighteenth_birthday_before_raid = datetime.date(2008, 4, 30)
+ assert (
+ will_birthday_be_minor_on(
+ eighteenth_birthday_before_raid,
+ raid_date,
+ )
+ is False
+ )
+
+
+def test_minor_if_birthday_after_raid() -> None:
+ raid_date = datetime.date(2026, 5, 1)
+ eighteenth_birthday_after_raid = datetime.date(2008, 5, 2)
+ assert (
+ will_birthday_be_minor_on(
+ eighteenth_birthday_after_raid,
+ raid_date,
+ )
+ is True
+ )
+
+
+# -- calculate_raid_payment (new enum semantics) ---------------------------
+
+
+@pytest.fixture
+def prices() -> coredata_raid.RaidPrice:
+ return coredata_raid.RaidPrice(
+ student_price=50,
+ t_shirt_price=15,
+ external_price=90,
+ )
+
+
+def _participant(**kwargs):
+ defaults: dict[str, Any] = {
+ "user_id": str(uuid4()),
+ "edition_id": uuid4(),
+ "payment": False,
+ "t_shirt_payment": False,
+ "t_shirt_size": None,
+ "situation": None,
+ "student_card_id": None,
+ }
+ defaults.update(kwargs)
+ return RaidParticipant(**defaults)
+
+
+def test_payment_centrale_with_student_card(prices) -> None:
+ p = _participant(
+ situation=Situation.centrale,
+ student_card_id=str(uuid.uuid4()),
+ )
+ price, label = calculate_raid_payment(p, prices)
+ assert price == 50
+ assert "étudiant" in label
+
+
+def test_payment_other_school_with_student_card(prices) -> None:
+ p = _participant(
+ situation=Situation.otherSchool,
+ student_card_id=str(uuid.uuid4()),
+ )
+ price, label = calculate_raid_payment(p, prices)
+ assert price == 50
+ assert "étudiant" in label
+
+
+def test_payment_centrale_without_student_card_falls_to_external(prices) -> None:
+ p = _participant(situation=Situation.centrale, student_card_id=None)
+ price, label = calculate_raid_payment(p, prices)
+ assert price == 90
+ assert "externe" in label
+
+
+def test_payment_other_situation_is_external(prices) -> None:
+ p = _participant(situation=Situation.other)
+ price, _ = calculate_raid_payment(p, prices)
+ assert price == 90
+
+
+def test_payment_corporate_partner_is_external(prices) -> None:
+ p = _participant(situation=Situation.corporatePartner)
+ price, _ = calculate_raid_payment(p, prices)
+ assert price == 90
+
+
+def test_payment_adds_tshirt(prices) -> None:
+ p = _participant(
+ situation=Situation.centrale,
+ student_card_id=str(uuid.uuid4()),
+ t_shirt_size=Size.L,
+ )
+ price, _ = calculate_raid_payment(p, prices)
+ assert price == 65 # 50 student + 15 t-shirt
+
+
+def test_payment_none_size_tshirt_not_billed(prices) -> None:
+ p = _participant(
+ situation=Situation.centrale,
+ student_card_id=str(uuid.uuid4()),
+ t_shirt_size=Size.None_,
+ )
+ price, _ = calculate_raid_payment(p, prices)
+ assert price == 50
+
+
+def test_payment_already_paid_zero(prices) -> None:
+ p = _participant(
+ situation=Situation.centrale,
+ student_card_id=str(uuid.uuid4()),
+ payment=True,
+ )
+ price, _ = calculate_raid_payment(p, prices)
+ assert price == 0
+
+
+def test_payment_already_paid_but_tshirt_outstanding(prices) -> None:
+ p = _participant(
+ situation=Situation.centrale,
+ student_card_id=str(uuid.uuid4()),
+ payment=True,
+ t_shirt_size=Size.L,
+ t_shirt_payment=False,
+ )
+ price, _ = calculate_raid_payment(p, prices)
+ assert price == 15
+
+
+def test_payment_fully_settled_zero(prices) -> None:
+ p = _participant(
+ situation=Situation.centrale,
+ student_card_id=str(uuid.uuid4()),
+ payment=True,
+ t_shirt_size=Size.L,
+ t_shirt_payment=True,
+ )
+ price, _ = calculate_raid_payment(p, prices)
+ assert price == 0
+
+
+def test_payment_raises_if_prices_missing() -> None:
+ bad_prices = coredata_raid.RaidPrice(
+ student_price=None,
+ t_shirt_price=None,
+ external_price=None,
+ )
+ p = _participant(situation=Situation.other)
+ with pytest.raises(HTTPException) as exc_info:
+ calculate_raid_payment(p, bad_prices)
+ assert exc_info.value.status_code == 404
+
+
+# -- set_team_number -------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_set_team_number_noop_without_difficulty(mocker: MockerFixture) -> None:
+ db = AsyncMock()
+ team = Mock(spec=RaidTeam, id="tid", difficulty=None)
+ mock_max = mocker.patch(
+ "app.modules.raid.cruds_raid.get_max_team_number_by_difficulty",
+ return_value=0,
+ )
+ mock_update = mocker.patch("app.modules.raid.cruds_raid.update_team")
+ await set_team_number(team, uuid4(), db)
+ mock_max.assert_not_called()
+ mock_update.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_set_team_number_discovery_empty(mocker: MockerFixture) -> None:
+ db = AsyncMock()
+ team = Mock(spec=RaidTeam, id="tid", difficulty=Difficulty.discovery)
+ mocker.patch(
+ "app.modules.raid.cruds_raid.get_max_team_number_by_difficulty",
+ return_value=0,
+ )
+ mock_update = mocker.patch("app.modules.raid.cruds_raid.update_team")
+ await set_team_number(team, uuid4(), db)
+ args, _ = mock_update.call_args
+ assert args[1].number == 1
+
+
+@pytest.mark.asyncio
+async def test_set_team_number_sports_empty(mocker: MockerFixture) -> None:
+ db = AsyncMock()
+ team = Mock(spec=RaidTeam, id="tid", difficulty=Difficulty.sports)
+ mocker.patch(
+ "app.modules.raid.cruds_raid.get_max_team_number_by_difficulty",
+ return_value=0,
+ )
+ mock_update = mocker.patch("app.modules.raid.cruds_raid.update_team")
+ await set_team_number(team, uuid4(), db)
+ args, _ = mock_update.call_args
+ assert args[1].number == 101
+
+
+@pytest.mark.asyncio
+async def test_set_team_number_expert_with_existing(mocker: MockerFixture) -> None:
+ db = AsyncMock()
+ team = Mock(spec=RaidTeam, id="tid", difficulty=Difficulty.expert)
+ mocker.patch(
+ "app.modules.raid.cruds_raid.get_max_team_number_by_difficulty",
+ return_value=205,
+ )
+ mock_update = mocker.patch("app.modules.raid.cruds_raid.update_team")
+ await set_team_number(team, uuid4(), db)
+ args, _ = mock_update.call_args
+ assert args[1].number == 206
+
+
+@pytest.mark.asyncio
+async def test_set_team_number_passes_edition_to_crud(mocker: MockerFixture) -> None:
+ db = AsyncMock()
+ edition_id = uuid4()
+ team = Mock(spec=RaidTeam, id="tid", difficulty=Difficulty.sports)
+ mock_max = mocker.patch(
+ "app.modules.raid.cruds_raid.get_max_team_number_by_difficulty",
+ return_value=0,
+ )
+ mocker.patch("app.modules.raid.cruds_raid.update_team")
+ await set_team_number(team, edition_id, db)
+ args, _ = mock_max.call_args
+ assert args[0] == Difficulty.sports
+ assert args[1] == edition_id
diff --git a/tests/modules/raid/test_utils_raid_extended.py b/tests/modules/raid/test_utils_raid_extended.py
new file mode 100644
index 0000000000..5a324e73b3
--- /dev/null
+++ b/tests/modules/raid/test_utils_raid_extended.py
@@ -0,0 +1,549 @@
+"""Extended tests for app/modules/raid/utils/utils_raid.py.
+
+This file addresses the 0.8% test coverage gap in the raid module by covering
+additional functions in utils_raid.py that were not previously tested.
+"""
+
+import datetime
+from unittest.mock import AsyncMock, Mock, patch
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.payment import schemas_payment
+from app.modules.raid import coredata_raid, schemas_raid
+from app.modules.raid.raid_type import (
+ Difficulty,
+ Situation,
+ Size,
+)
+from app.modules.raid.utils.utils_raid import (
+ RaidPayementError,
+ _participant_pdf_context,
+ calculate_raid_payment,
+ get_participant,
+ set_team_number,
+ validate_payment,
+)
+
+# --- Helper functions -----------------------------------------------------
+
+
+async def _create_mock_db():
+ """Create a mock async database session."""
+ return AsyncMock(spec=AsyncSession)
+
+
+def _create_mock_user():
+ """Create a mock CoreUser for testing."""
+ user = Mock()
+ user.id = str(uuid4())
+ user.name = "Test"
+ user.firstname = "User"
+ user.email = "test@example.com"
+ user.phone = "+33123456789"
+ user.birthday = datetime.date(1990, 1, 1)
+ return user
+
+
+# --- validate_payment tests -----------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_validate_payment_success_student():
+ """Test validate_payment with student price."""
+ db = AsyncMock()
+ # Mock the checkout payment
+ checkout_payment = schemas_payment.CheckoutPayment(
+ id=uuid4(),
+ checkout_id=uuid4(),
+ paid_amount=50.0,
+ )
+
+ # Mock the participant checkout
+ participant_checkout = Mock()
+ participant_checkout.participant_user_id = "user_123"
+ participant_checkout.edition_id = uuid4()
+
+ # Mock prices
+ prices = Mock()
+ prices.student_price = 50.0
+ prices.external_price = 90.0
+ prices.t_shirt_price = 15.0
+
+ # Mock dependencies
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds:
+ mock_cruds.get_participant_checkout_by_checkout_id = AsyncMock(
+ return_value=participant_checkout,
+ )
+ mock_cruds.confirm_payment = AsyncMock()
+
+ with patch(
+ "app.modules.raid.utils.utils_raid.get_core_data",
+ new=AsyncMock(return_value=prices),
+ ):
+ await validate_payment(checkout_payment, db)
+
+ # Verify payment confirmation
+ mock_cruds.confirm_payment.assert_called_once_with(
+ "user_123",
+ participant_checkout.edition_id,
+ db,
+ )
+
+
+@pytest.mark.asyncio
+async def test_validate_payment_success_tshirt():
+ """Test validate_payment with t-shirt price only."""
+ db = AsyncMock()
+ checkout_payment = schemas_payment.CheckoutPayment(
+ id=uuid4(),
+ checkout_id=uuid4(),
+ paid_amount=15.0,
+ )
+
+ participant_checkout = Mock()
+ participant_checkout.participant_user_id = "user_456"
+ participant_checkout.edition_id = uuid4()
+
+ prices = Mock()
+ prices.student_price = 50.0
+ prices.external_price = 90.0
+ prices.t_shirt_price = 15.0
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds:
+ mock_cruds.get_participant_checkout_by_checkout_id = AsyncMock(
+ return_value=participant_checkout,
+ )
+ mock_cruds.confirm_t_shirt_payment = AsyncMock()
+
+ with patch(
+ "app.modules.raid.utils.utils_raid.get_core_data",
+ new=AsyncMock(return_value=prices),
+ ):
+ await validate_payment(checkout_payment, db)
+
+ mock_cruds.confirm_t_shirt_payment.assert_called_once_with(
+ "user_456",
+ participant_checkout.edition_id,
+ db,
+ )
+
+
+@pytest.mark.asyncio
+async def test_validate_payment_raised_when_checkout_not_found():
+ """Test validate_payment raises RaidPayementError when checkout not found."""
+ checkout_payment = schemas_payment.CheckoutPayment(
+ id=uuid4(),
+ checkout_id=uuid4(),
+ paid_amount=50.0,
+ )
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds:
+ mock_cruds.get_participant_checkout_by_checkout_id = AsyncMock(
+ return_value=None,
+ )
+
+ with pytest.raises(RaidPayementError) as exc_info:
+ await validate_payment(checkout_payment, AsyncMock())
+
+ assert "not found" in str(exc_info.value)
+
+
+# --- set_team_number tests -----------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_set_team_number_success():
+ """Test set_team_number with a difficulty."""
+ team = Mock(spec=schemas_raid.RaidTeam)
+ team.id = "team_123"
+ team.difficulty = Difficulty.sports
+
+ edition_id = uuid4()
+ db = AsyncMock()
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds:
+ mock_cruds.get_max_team_number_by_difficulty = AsyncMock(return_value=0)
+ mock_cruds.update_team = AsyncMock()
+
+ await set_team_number(team, edition_id, db)
+
+ # Verify max team number was retrieved and team updated
+ mock_cruds.get_max_team_number_by_difficulty.assert_called_once_with(
+ Difficulty.sports,
+ edition_id,
+ db,
+ )
+ mock_cruds.update_team.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_set_team_number_with_existing_team():
+ """Test set_team_number when teams already exist."""
+ team = Mock(spec=schemas_raid.RaidTeam)
+ team.id = "team_456"
+ team.difficulty = Difficulty.expert
+
+ edition_id = uuid4()
+ db = AsyncMock()
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds:
+ mock_cruds.get_max_team_number_by_difficulty = AsyncMock(return_value=205)
+ mock_cruds.update_team = AsyncMock()
+
+ await set_team_number(team, edition_id, db)
+
+ # Should increment from existing max
+ mock_cruds.update_team.assert_called_once()
+
+
+# --- _participant_pdf_context tests --------------------------------------
+
+
+def test_participant_pdf_context_with_user():
+ """Test _participant_pdf_context when user is present."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.model_dump.return_value = {
+ "user_id": "test_user",
+ "edition_id": uuid4(),
+ }
+
+ user = Mock()
+ user.name = "Doe"
+ user.firstname = "John"
+ user.email = "john@example.com"
+ user.phone = "+33123456789"
+ user.birthday = datetime.date(1990, 1, 1)
+ participant.user = user
+
+ result = _participant_pdf_context(participant)
+
+ assert result["user_id"] == "test_user"
+ assert result["name"] == "Doe"
+ assert result["firstname"] == "John"
+ assert result["email"] == "john@example.com"
+ assert result["phone"] == "+33123456789"
+ assert result["birthday"] == datetime.date(1990, 1, 1)
+
+
+def test_participant_pdf_context_without_user():
+ """Test _participant_pdf_context when user is None."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.model_dump.return_value = {
+ "user_id": "test_user",
+ "edition_id": uuid4(),
+ }
+ participant.user = None
+
+ result = _participant_pdf_context(participant)
+
+ assert result["user_id"] == "test_user"
+ assert "name" not in result
+ assert "firstname" not in result
+
+
+# --- get_participant tests ------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_get_participant_success():
+ """Test get_participant when participant exists."""
+ user_id = "user_123"
+ edition_id = uuid4()
+ db = AsyncMock()
+
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.user_id = user_id
+ participant.edition_id = edition_id
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds:
+ mock_cruds.get_participant_by_user_id = AsyncMock(return_value=participant)
+
+ result = await get_participant(user_id, edition_id, db)
+
+ assert result is participant
+ mock_cruds.get_participant_by_user_id.assert_called_once_with(
+ user_id,
+ edition_id,
+ db,
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_participant_not_found():
+ """Test get_participant when participant doesn't exist."""
+ user_id = "non_existent_user"
+ edition_id = uuid4()
+ db = AsyncMock()
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds:
+ mock_cruds.get_participant_by_user_id = AsyncMock(return_value=None)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_participant(user_id, edition_id, db)
+
+ assert exc_info.value.status_code == 404
+ assert "Participant not found" in exc_info.value.detail
+
+
+# --- calculate_raid_payment tests (NEW) -----------------------------------
+
+
+def test_calculate_raid_payment_student_with_card():
+ """Test calculate_raid_payment for student with student card."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.centrale
+ participant.student_card_id = "card_123"
+ participant.payment = False
+ participant.t_shirt_size = None
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, checkout_name = calculate_raid_payment(participant, prices)
+
+ assert price == 50.0
+ assert "étudiant" in checkout_name
+
+
+def test_calculate_raid_payment_student_without_card():
+ """Test calculate_raid_payment falls back to external without student card."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.centrale
+ participant.student_card_id = None
+ participant.payment = False
+ participant.t_shirt_size = None
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, checkout_name = calculate_raid_payment(participant, prices)
+
+ assert price == 90.0
+ assert "externe" in checkout_name
+
+
+def test_calculate_raid_payment_other_school():
+ """Test calculate_raid_payment for otherSchool situation."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.otherSchool
+ participant.student_card_id = "card_123"
+ participant.payment = False
+ participant.t_shirt_size = None
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, checkout_name = calculate_raid_payment(participant, prices)
+
+ assert price == 50.0
+ assert "étudiant" in checkout_name
+
+
+def test_calculate_raid_payment_corporate_partner():
+ """Test calculate_raid_payment for corporatePartner situation."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.corporatePartner
+ participant.student_card_id = "card_123"
+ participant.payment = False
+ participant.t_shirt_size = None
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, _ = calculate_raid_payment(participant, prices)
+
+ assert price == 90.0 # Corporate partner is always external
+
+
+def test_calculate_raid_payment_with_tshirt():
+ """Test calculate_raid_payment includes t-shirt when applicable."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.centrale
+ participant.student_card_id = "card_123"
+ participant.payment = False
+ participant.t_shirt_size = Size.L
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, _ = calculate_raid_payment(participant, prices)
+
+ assert price == 65.0 # 50 student + 15 t-shirt
+
+
+def test_calculate_raid_payment_already_paid():
+ """Test calculate_raid_payment returns 0 when already paid."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.centrale
+ participant.student_card_id = "card_123"
+ participant.payment = True
+ participant.t_shirt_size = None
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, _ = calculate_raid_payment(participant, prices)
+
+ assert price == 0
+
+
+def test_calculate_raid_payment_tshirt_alone():
+ """Test calculate_raid_payment with only t-shirt payment."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.centrale
+ participant.student_card_id = "card_123"
+ participant.payment = True
+ participant.t_shirt_size = Size.L
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, _ = calculate_raid_payment(participant, prices)
+
+ assert price == 15.0 # Only t-shirt
+
+
+def test_calculate_raid_payment_fully_paid():
+ """Test calculate_raid_payment with everything paid."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.centrale
+ participant.student_card_id = "card_123"
+ participant.payment = True
+ participant.t_shirt_size = Size.L
+ participant.t_shirt_payment = True
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, _ = calculate_raid_payment(participant, prices)
+
+ assert price == 0 # Everything paid
+
+
+def test_calculate_raid_payment_invalid_price():
+ """Test calculate_raid_payment raises HTTPException when prices invalid."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = Situation.other
+ participant.student_card_id = None
+ participant.payment = False
+ participant.t_shirt_size = None
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=None,
+ t_shirt_price=None,
+ external_price=None,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ calculate_raid_payment(participant, prices)
+
+ assert exc_info.value.status_code == 404
+ assert "Prices not set" in exc_info.value.detail
+
+
+# --- Test coverage for calculate_raid_payment with None situation ---------------------------
+
+
+def test_calculate_raid_payment_situation_none():
+ """Test calculate_raid_payment when situation is None."""
+ participant = Mock(spec=schemas_raid.RaidParticipant)
+ participant.situation = None
+ participant.student_card_id = None
+ participant.payment = False
+ participant.t_shirt_size = None
+ participant.t_shirt_payment = False
+
+ prices = coredata_raid.RaidPrice(
+ student_price=50.0,
+ t_shirt_price=15.0,
+ external_price=90.0,
+ )
+
+ price, _ = calculate_raid_payment(participant, prices)
+
+ assert price == 90.0 # None falls back to external
+
+
+# --- Additional edge cases -----------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_validate_payment_all_combinations():
+ """Test various payment combinations."""
+ # Test student + t-shirt combination
+ db = AsyncMock()
+ checkout_payment = schemas_payment.CheckoutPayment(
+ id=uuid4(),
+ checkout_id=uuid4(),
+ paid_amount=65.0, # student + t-shirt
+ )
+
+ participant_checkout = Mock()
+ participant_checkout.participant_user_id = "user_789"
+ participant_checkout.edition_id = uuid4()
+
+ prices = Mock()
+ prices.student_price = 50.0
+ prices.external_price = 90.0
+ prices.t_shirt_price = 15.0
+
+ with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds:
+ mock_cruds.get_participant_checkout_by_checkout_id = AsyncMock(
+ return_value=participant_checkout,
+ )
+ mock_cruds.confirm_payment = AsyncMock()
+ mock_cruds.confirm_t_shirt_payment = AsyncMock()
+
+ with patch(
+ "app.modules.raid.utils.utils_raid.get_core_data",
+ new=AsyncMock(return_value=prices),
+ ):
+ await validate_payment(checkout_payment, db)
+
+ # Both should be confirmed for student + t-shirt combination
+ mock_cruds.confirm_payment.assert_called_once_with(
+ "user_789",
+ participant_checkout.edition_id,
+ db,
+ )
+ mock_cruds.confirm_t_shirt_payment.assert_called_once_with(
+ "user_789",
+ participant_checkout.edition_id,
+ db,
+ )
diff --git a/tests/modules/raid/test_validation_checker.py b/tests/modules/raid/test_validation_checker.py
new file mode 100644
index 0000000000..752197eeb1
--- /dev/null
+++ b/tests/modules/raid/test_validation_checker.py
@@ -0,0 +1,481 @@
+"""Unit tests for app/modules/raid/utils/validation_checker.py.
+
+These tests exercise the fine-grained sub-checks that gate a participant or a
+volunteer moving to `validated`, plus the pure progress/count helpers used by
+the API's computed fields. The sub-checks raise HTTPException(400) with a
+distinct `detail` string per failure so the frontend can i18n cleanly; we
+assert the exact strings so they stay stable.
+"""
+
+# ruff: noqa: SLF001 # tests deliberately exercise private sub-checks
+
+from unittest.mock import AsyncMock, Mock
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+
+from app.modules.raid import cruds_raid, models_raid
+from app.modules.raid.raid_type import (
+ Difficulty,
+ DocumentValidation,
+ MeetingPlace,
+ Situation,
+ Size,
+)
+from app.modules.raid.utils import validation_checker
+
+
+def _make_doc(validation: DocumentValidation) -> Mock:
+ return Mock(spec=models_raid.Document, validation=validation)
+
+
+def _make_security_file(with_contacts: bool = True) -> Mock:
+ if with_contacts:
+ return Mock(
+ spec=models_raid.SecurityFile,
+ emergency_person_firstname="Jane",
+ emergency_person_name="Doe",
+ emergency_person_phone="0600000000",
+ )
+ return Mock(
+ spec=models_raid.SecurityFile,
+ emergency_person_firstname=None,
+ emergency_person_name=None,
+ emergency_person_phone=None,
+ )
+
+
+def _make_validated_participant(
+ *,
+ edition_id=None,
+ situation: Situation = Situation.centrale,
+ is_minor: bool = False,
+ with_student_card: bool | None = None,
+ with_parent_auth: bool | None = None,
+ payment: bool = True,
+ t_shirt_size: Size | None = None,
+ t_shirt_payment: bool = True,
+ attestation: bool = True,
+ with_security_file: bool = True,
+ security_contacts: bool = True,
+) -> Mock:
+ """Assemble a participant that would pass every check by default."""
+ edition_id = edition_id or uuid4()
+ if with_student_card is None:
+ with_student_card = situation in (Situation.centrale, Situation.otherSchool)
+ if with_parent_auth is None:
+ with_parent_auth = is_minor
+
+ participant = Mock(spec=models_raid.RaidParticipant)
+ participant.user_id = "user-id"
+ participant.edition_id = edition_id
+ participant.situation = situation
+ participant.is_minor = is_minor
+ participant.attestation_on_honour = attestation
+ participant.payment = payment
+ participant.t_shirt_size = t_shirt_size
+ participant.t_shirt_payment = t_shirt_payment
+ participant.id_card = _make_doc(DocumentValidation.accepted)
+ participant.medical_certificate = _make_doc(DocumentValidation.accepted)
+ participant.raid_rules = _make_doc(DocumentValidation.accepted)
+ participant.student_card = (
+ _make_doc(DocumentValidation.accepted) if with_student_card else None
+ )
+ participant.parent_authorization = (
+ _make_doc(DocumentValidation.accepted) if with_parent_auth else None
+ )
+ participant.security_file = (
+ _make_security_file(security_contacts) if with_security_file else None
+ )
+ participant.security_file_id = (
+ "some-security-file-id" if with_security_file else None
+ )
+ participant.address = "123 rue"
+ participant.bike_size = Size.M
+ participant.user = Mock(phone="0600000000")
+ return participant
+
+
+# -- _check_edition_scope ---------------------------------------------------
+
+
+def test_check_edition_scope_rejects_wrong_edition() -> None:
+ p = _make_validated_participant()
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_edition_scope(p, uuid4())
+ assert exc_info.value.status_code == 400
+ assert exc_info.value.detail == (
+ "Participant does not belong to the current edition"
+ )
+
+
+def test_check_edition_scope_accepts_matching_edition() -> None:
+ eid = uuid4()
+ p = _make_validated_participant(edition_id=eid)
+ validation_checker._check_edition_scope(p, eid) # no raise
+
+
+# -- _check_attestation_signed ----------------------------------------------
+
+
+def test_check_attestation_signed_rejects_when_unsigned() -> None:
+ p = _make_validated_participant(attestation=False)
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_attestation_signed(p)
+ assert exc_info.value.status_code == 400
+ assert exc_info.value.detail == (
+ "Participant has not signed the attestation on honour"
+ )
+
+
+def test_check_attestation_signed_accepts_when_signed() -> None:
+ p = _make_validated_participant(attestation=True)
+ validation_checker._check_attestation_signed(p)
+
+
+# -- _check_payment_done ---------------------------------------------------
+
+
+def test_check_payment_done_rejects_when_unpaid() -> None:
+ p = _make_validated_participant(payment=False)
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_payment_done(p)
+ assert exc_info.value.detail == "Participant payment is not done"
+
+
+def test_check_payment_done_rejects_when_tshirt_unpaid() -> None:
+ p = _make_validated_participant(t_shirt_size=Size.M, t_shirt_payment=False)
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_payment_done(p)
+ assert exc_info.value.detail == "Participant t-shirt payment is not done"
+
+
+def test_check_payment_done_ignores_none_size() -> None:
+ p = _make_validated_participant(t_shirt_size=Size.None_, t_shirt_payment=False)
+ validation_checker._check_payment_done(p) # should not raise
+
+
+def test_check_payment_done_ignores_null_tshirt_size() -> None:
+ p = _make_validated_participant(t_shirt_size=None, t_shirt_payment=False)
+ validation_checker._check_payment_done(p)
+
+
+# -- _check_security_file_complete -----------------------------------------
+
+
+def test_check_security_file_missing() -> None:
+ p = _make_validated_participant(with_security_file=False)
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_security_file_complete(p)
+ assert exc_info.value.detail == "Participant has no security file"
+
+
+def test_check_security_file_missing_emergency_contact() -> None:
+ p = _make_validated_participant(
+ with_security_file=True,
+ security_contacts=False,
+ )
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_security_file_complete(p)
+ assert exc_info.value.detail == (
+ "Participant security file is missing emergency contact"
+ )
+
+
+def test_check_security_file_complete_passes() -> None:
+ p = _make_validated_participant()
+ validation_checker._check_security_file_complete(p)
+
+
+# -- _check_all_documents_accepted ----------------------------------------
+
+
+def test_check_all_documents_accepted_passes_for_centrale() -> None:
+ p = _make_validated_participant(situation=Situation.centrale)
+ validation_checker._check_all_documents_accepted(p)
+
+
+def test_check_all_documents_accepted_passes_for_other() -> None:
+ p = _make_validated_participant(
+ situation=Situation.other,
+ with_student_card=False,
+ )
+ validation_checker._check_all_documents_accepted(p)
+
+
+def test_check_all_documents_accepted_requires_student_card_for_otherschool() -> None:
+ p = _make_validated_participant(
+ situation=Situation.otherSchool,
+ with_student_card=False,
+ )
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_all_documents_accepted(p)
+ assert exc_info.value.detail == "Missing student card"
+
+
+def test_check_all_documents_accepted_requires_parent_auth_when_minor() -> None:
+ p = _make_validated_participant(
+ situation=Situation.centrale,
+ is_minor=True,
+ with_parent_auth=False,
+ )
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_all_documents_accepted(p)
+ assert exc_info.value.detail == "Missing parent authorization"
+
+
+def test_check_all_documents_accepted_rejects_pending_doc() -> None:
+ p = _make_validated_participant()
+ p.id_card = _make_doc(DocumentValidation.pending)
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_all_documents_accepted(p)
+ assert exc_info.value.detail == "Document id card is not accepted"
+
+
+def test_check_all_documents_accepted_rejects_missing_id_card() -> None:
+ p = _make_validated_participant()
+ p.id_card = None
+ with pytest.raises(HTTPException) as exc_info:
+ validation_checker._check_all_documents_accepted(p)
+ assert exc_info.value.detail == "Missing id card"
+
+
+# -- check_participant_validation_consistency (full orchestrator) --------
+
+
+@pytest.mark.asyncio
+async def test_full_participant_checker_passes_for_valid_data() -> None:
+
+ edition_id = uuid4()
+ p = _make_validated_participant(edition_id=edition_id)
+ team = Mock(
+ spec=models_raid.RaidTeam,
+ second=Mock(),
+ difficulty=Difficulty.sports,
+ meeting_place=MeetingPlace.centrale,
+ )
+
+ original = cruds_raid.get_team_by_participant_id
+ cruds_raid.get_team_by_participant_id = AsyncMock(return_value=team)
+ try:
+ await validation_checker.check_participant_validation_consistency(
+ p,
+ edition_id,
+ AsyncMock(),
+ )
+ finally:
+ cruds_raid.get_team_by_participant_id = original
+
+
+@pytest.mark.asyncio
+async def test_full_participant_checker_fails_when_team_incomplete() -> None:
+
+ edition_id = uuid4()
+ p = _make_validated_participant(edition_id=edition_id)
+ team_no_second = Mock(
+ spec=models_raid.RaidTeam,
+ second=None,
+ difficulty=Difficulty.sports,
+ meeting_place=MeetingPlace.centrale,
+ )
+
+ original = cruds_raid.get_team_by_participant_id
+ cruds_raid.get_team_by_participant_id = AsyncMock(return_value=team_no_second)
+ try:
+ with pytest.raises(HTTPException) as exc_info:
+ await validation_checker.check_participant_validation_consistency(
+ p,
+ edition_id,
+ AsyncMock(),
+ )
+ assert exc_info.value.detail == "Team is missing a second member"
+ finally:
+ cruds_raid.get_team_by_participant_id = original
+
+
+@pytest.mark.asyncio
+async def test_full_participant_checker_fails_when_no_team() -> None:
+
+ edition_id = uuid4()
+ p = _make_validated_participant(edition_id=edition_id)
+
+ original = cruds_raid.get_team_by_participant_id
+ cruds_raid.get_team_by_participant_id = AsyncMock(return_value=None)
+ try:
+ with pytest.raises(HTTPException) as exc_info:
+ await validation_checker.check_participant_validation_consistency(
+ p,
+ edition_id,
+ AsyncMock(),
+ )
+ assert exc_info.value.detail == "Participant is not in a team"
+ finally:
+ cruds_raid.get_team_by_participant_id = original
+
+
+# -- Volunteer checker ------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_check_volunteer_rejects_wrong_edition() -> None:
+
+ v = Mock(
+ spec=models_raid.RaidVolunteer,
+ edition_id=uuid4(),
+ user=Mock(phone="06"),
+ emergency_person_name="N",
+ emergency_person_phone="06",
+ )
+ with pytest.raises(HTTPException) as exc_info:
+ await validation_checker.check_volunteer_validation_consistency(
+ v,
+ uuid4(),
+ AsyncMock(),
+ )
+ assert exc_info.value.detail == ("Volunteer does not belong to the current edition")
+
+
+@pytest.mark.asyncio
+async def test_check_volunteer_rejects_missing_phone() -> None:
+
+ eid = uuid4()
+ v = Mock(
+ spec=models_raid.RaidVolunteer,
+ edition_id=eid,
+ user=Mock(phone=None),
+ emergency_person_name="N",
+ emergency_person_phone="06",
+ )
+ with pytest.raises(HTTPException) as exc_info:
+ await validation_checker.check_volunteer_validation_consistency(
+ v,
+ eid,
+ AsyncMock(),
+ )
+ assert exc_info.value.detail == ("Volunteer phone is not set on the user profile")
+
+
+@pytest.mark.asyncio
+async def test_check_volunteer_rejects_missing_emergency_contact() -> None:
+
+ eid = uuid4()
+ v = Mock(
+ spec=models_raid.RaidVolunteer,
+ edition_id=eid,
+ user=Mock(phone="06"),
+ emergency_person_name=None,
+ emergency_person_phone="06",
+ )
+ with pytest.raises(HTTPException) as exc_info:
+ await validation_checker.check_volunteer_validation_consistency(
+ v,
+ eid,
+ AsyncMock(),
+ )
+ assert exc_info.value.detail == "Volunteer emergency contact is incomplete"
+
+
+@pytest.mark.asyncio
+async def test_check_volunteer_passes_for_complete_profile() -> None:
+
+ eid = uuid4()
+ v = Mock(
+ spec=models_raid.RaidVolunteer,
+ edition_id=eid,
+ user=Mock(phone="06"),
+ emergency_person_name="Jane",
+ emergency_person_phone="06",
+ has_car=False,
+ car_seats=None,
+ )
+ await validation_checker.check_volunteer_validation_consistency(
+ v,
+ eid,
+ AsyncMock(),
+ )
+
+
+# -- Pure helpers: progress + counts ---------------------------------------
+
+
+def test_compute_participant_progress_zero_for_empty_profile() -> None:
+ p = Mock(
+ spec=models_raid.RaidParticipant,
+ address=None,
+ bike_size=None,
+ t_shirt_size=None,
+ situation=None,
+ attestation_on_honour=None,
+ is_minor=False,
+ id_card=None,
+ medical_certificate=None,
+ security_file=None,
+ raid_rules=None,
+ student_card=None,
+ parent_authorization=None,
+ )
+ assert validation_checker.compute_participant_progress(p) == 0.0
+
+
+def test_compute_participant_progress_high_for_complete_profile() -> None:
+ p = _make_validated_participant(situation=Situation.centrale)
+ # All documents accepted + most flags set -> well above partial.
+ progress = validation_checker.compute_participant_progress(p)
+ assert progress >= 70
+
+
+def test_compute_participant_progress_partial_gives_fraction() -> None:
+ p = Mock(
+ spec=models_raid.RaidParticipant,
+ address="x",
+ bike_size=Size.M,
+ t_shirt_size=None,
+ situation=None,
+ attestation_on_honour=None,
+ is_minor=False,
+ id_card=_make_doc(DocumentValidation.accepted),
+ medical_certificate=None,
+ security_file=None,
+ raid_rules=None,
+ student_card=None,
+ parent_authorization=None,
+ )
+ progress = validation_checker.compute_participant_progress(p)
+ assert 0 < progress < 100
+
+
+def test_count_total_required_documents_centrale() -> None:
+ p = Mock(
+ spec=models_raid.RaidParticipant,
+ situation=Situation.centrale,
+ is_minor=False,
+ )
+ assert validation_checker.count_total_required_documents(p) == 4
+
+
+def test_count_total_required_documents_other_minor() -> None:
+ p = Mock(spec=models_raid.RaidParticipant, situation=Situation.other, is_minor=True)
+ assert validation_checker.count_total_required_documents(p) == 4
+
+
+def test_count_total_required_documents_centrale_minor() -> None:
+ p = Mock(
+ spec=models_raid.RaidParticipant,
+ situation=Situation.centrale,
+ is_minor=True,
+ )
+ assert validation_checker.count_total_required_documents(p) == 5
+
+
+def test_count_accepted_documents_all_present() -> None:
+ p = _make_validated_participant(situation=Situation.centrale, is_minor=True)
+ # id_card + medical_certificate + raid_rules + student_card + parent_authorization
+ assert validation_checker.count_accepted_documents(p) == 5
+
+
+def test_count_accepted_documents_pending_not_counted() -> None:
+ p = _make_validated_participant()
+ p.id_card = _make_doc(DocumentValidation.pending)
+ # lost id_card -> 2 (medical + raid_rules) + student_card
+ assert validation_checker.count_accepted_documents(p) == 3
diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py
index e9f25dcf26..630e94e567 100644
--- a/tests/modules/test_raid.py
+++ b/tests/modules/test_raid.py
@@ -1,1039 +1,991 @@
+"""End-to-end tests for the raid module endpoints.
+
+Covers the full participant registration flow with the new edition scoping +
+state machine, team lifecycle, volunteer registration flow, admin validation
+gates, and permission enforcement. Identity fields (name/firstname/email/
+birthday/phone) now live on `CoreUser`; tests set them via `update_user` so
+the participant/volunteer payloads stay small and mirror the real API shape.
+"""
+
+import asyncio
import datetime
-import shutil
import uuid
-from unittest.mock import Mock
import pytest
import pytest_asyncio
-from anyio import Path
from fastapi.testclient import TestClient
-from pytest_mock import MockerFixture
+from sqlalchemy import update
from app.core.groups import models_groups
-from app.core.users import models_users
-from app.modules.raid import coredata_raid, models_raid
+from app.core.users import cruds_users, models_users, schemas_users
+from app.modules.raid import coredata_raid, cruds_raid, models_raid, schemas_raid
from app.modules.raid.endpoints_raid import RaidPermissions
-from app.modules.raid.models_raid import (
- RaidParticipant,
- RaidTeam,
- SecurityFile,
-)
from app.modules.raid.raid_type import (
Difficulty,
DocumentType,
DocumentValidation,
MeetingPlace,
+ RaidRegistrationStatus,
+ Situation,
Size,
)
-from app.modules.raid.utils.utils_raid import calculate_raid_payment, set_team_number
from tests.commons import (
add_coredata_to_db,
add_object_to_db,
create_api_access_token,
create_groups_with_permissions,
create_user_with_groups,
+ get_TestingSessionLocal,
)
-participant: models_raid.RaidParticipant
-admin_group: models_groups.CoreGroup
+# ---------------------------------------------------------------------------
+# Globals populated by the module-scoped init fixture.
+# ---------------------------------------------------------------------------
-team: models_raid.RaidTeam
-validated_team: models_raid.RaidTeam
+admin_group: models_groups.CoreGroup
-validated_document: models_raid.Document
+active_edition: models_raid.RaidEdition
raid_admin_user: models_users.CoreUser
-simple_user: models_users.CoreUser
-simple_user_without_participant: models_users.CoreUser
-simple_user_without_team: models_users.CoreUser
-
-validated_team_captain: models_users.CoreUser
-validated_team_second: models_users.CoreUser
-
-token_raid_admin: str
-token_simple: str
-token_simple_without_participant: str
-token_simple_without_team: str
-
-token_validated_team_captain: str
+user_captain: models_users.CoreUser
+user_second: models_users.CoreUser
+user_solo: models_users.CoreUser
+user_no_profile: models_users.CoreUser
+user_volunteer: models_users.CoreUser
+user_no_raid: models_users.CoreUser
+
+token_admin: str
+token_captain: str
+token_second: str
+token_solo: str
+token_no_profile: str
+token_volunteer: str
+token_no_raid: str
+
+doc_accepted: models_raid.Document
+doc_pending: models_raid.Document
+
+
+async def _set_user_identity(user_id: str, phone: str, birthday: datetime.date) -> None:
+ async with get_TestingSessionLocal()() as db:
+ await cruds_users.update_user(
+ db,
+ user_id,
+ schemas_users.CoreUserUpdateAdmin(phone=phone, birthday=birthday),
+ )
+ await db.commit()
@pytest_asyncio.fixture(scope="module", autouse=True)
async def init_objects() -> None:
- global admin_group
+
+ global admin_group, active_edition
admin_group = await create_groups_with_permissions(
[RaidPermissions.manage_raid],
"raid_admin",
)
- global raid_admin_user, token_raid_admin
- raid_admin_user = await create_user_with_groups([admin_group.id])
- token_raid_admin = create_api_access_token(raid_admin_user)
- global simple_user, token_simple
- simple_user = await create_user_with_groups(
- [],
+ edition = models_raid.RaidEdition(
+ id=uuid.uuid4(),
+ year=2026,
+ name="Raid 2026",
+ start_date=datetime.date(2026, 5, 1),
+ end_date=datetime.date(2026, 5, 3),
+ registering_end_date=datetime.date(2026, 4, 25),
+ active=True,
+ inscription_enabled=True,
)
- token_simple = create_api_access_token(simple_user)
+ await add_object_to_db(edition)
+ active_edition = edition
- global simple_user_without_participant, token_simple_without_participant
- simple_user_without_participant = await create_user_with_groups(
- [],
- )
- token_simple_without_participant = create_api_access_token(
- simple_user_without_participant,
+ await add_coredata_to_db(
+ coredata_raid.RaidPrice(
+ student_price=50,
+ t_shirt_price=15,
+ partner_price=70,
+ external_price=90,
+ ),
)
+ await add_coredata_to_db(coredata_raid.RaidInformation())
- global simple_user_without_team, token_simple_without_team
- simple_user_without_team = await create_user_with_groups(
- [],
+ global raid_admin_user, token_admin
+ raid_admin_user = await create_user_with_groups([admin_group.id])
+ await _set_user_identity(
+ raid_admin_user.id,
+ "+33600000000",
+ datetime.date(1990, 1, 1),
+ )
+ token_admin = create_api_access_token(raid_admin_user)
+
+ global user_captain, token_captain
+ user_captain = await create_user_with_groups([])
+ await _set_user_identity(user_captain.id, "+33611111111", datetime.date(2000, 6, 1))
+ token_captain = create_api_access_token(user_captain)
+
+ global user_second, token_second
+ user_second = await create_user_with_groups([])
+ await _set_user_identity(user_second.id, "+33622222222", datetime.date(2001, 3, 15))
+ token_second = create_api_access_token(user_second)
+
+ global user_solo, token_solo
+ user_solo = await create_user_with_groups([])
+ await _set_user_identity(user_solo.id, "+33633333333", datetime.date(1999, 11, 11))
+ token_solo = create_api_access_token(user_solo)
+
+ global user_no_profile, token_no_profile
+ user_no_profile = await create_user_with_groups([])
+ await _set_user_identity(
+ user_no_profile.id,
+ "+33644444444",
+ datetime.date(2002, 2, 2),
+ )
+ token_no_profile = create_api_access_token(user_no_profile)
+
+ global user_volunteer, token_volunteer
+ user_volunteer = await create_user_with_groups([])
+ await _set_user_identity(
+ user_volunteer.id,
+ "+33655555555",
+ datetime.date(1998, 7, 7),
+ )
+ token_volunteer = create_api_access_token(user_volunteer)
+
+ global user_no_raid, token_no_raid
+ user_no_raid = await create_user_with_groups([])
+ # Intentionally no identity update — POST /participants must 400.
+ token_no_raid = create_api_access_token(user_no_raid)
+
+ global doc_accepted, doc_pending
+ doc_accepted = models_raid.Document(
+ id=str(uuid.uuid4()),
+ edition_id=active_edition.id,
+ name="accepted.pdf",
+ uploaded_at=datetime.datetime.now(tz=datetime.UTC).date(),
+ type=DocumentType.idCard,
+ validation=DocumentValidation.accepted,
)
- token_simple_without_team = create_api_access_token(simple_user_without_team)
-
- global validated_team_captain, token_validated_team_captain
- validated_team_captain = await create_user_with_groups([])
- token_validated_team_captain = create_api_access_token(validated_team_captain)
-
- global validated_team_second
- validated_team_second = await create_user_with_groups([])
+ await add_object_to_db(doc_accepted)
- document = models_raid.Document(
- id="some_document_id",
- name="test.pdf",
- uploaded_at=datetime.datetime.now(tz=datetime.UTC),
+ doc_pending = models_raid.Document(
+ id=str(uuid.uuid4()),
+ edition_id=active_edition.id,
+ name="pending.pdf",
+ uploaded_at=datetime.datetime.now(tz=datetime.UTC).date(),
+ type=DocumentType.medicalCertificate,
validation=DocumentValidation.pending,
- type=DocumentType.idCard,
)
- await add_object_to_db(document)
+ await add_object_to_db(doc_pending)
- validated_document = models_raid.Document(
- id="6e9736ab-5ceb-42a8-a252-e8c66696f7b1",
- name="validated.pdf",
- uploaded_at=datetime.datetime.now(tz=datetime.UTC),
- validation=DocumentValidation.accepted,
- type=DocumentType.idCard,
+ captain_participant = models_raid.RaidParticipant(
+ user_id=user_captain.id,
+ edition_id=active_edition.id,
+ status=RaidRegistrationStatus.draft,
+ address="1 rue de la Doua",
+ bike_size=Size.M,
+ t_shirt_size=Size.M,
+ situation=Situation.centrale,
+ attestation_on_honour=False,
+ payment=False,
+ t_shirt_payment=False,
+ is_minor=False,
)
+ await add_object_to_db(captain_participant)
- await add_object_to_db(validated_document)
+ second_participant = models_raid.RaidParticipant(
+ user_id=user_second.id,
+ edition_id=active_edition.id,
+ status=RaidRegistrationStatus.draft,
+ situation=Situation.centrale,
+ is_minor=False,
+ )
+ await add_object_to_db(second_participant)
- global participant
- participant = models_raid.RaidParticipant(
- id=simple_user.id,
- firstname="TestFirstname",
- name="TestName",
- birthday=datetime.date(2001, 1, 1),
- phone="0606060606",
- email="test@email.fr",
- t_shirt_size=Size.M,
- id_card_id=document.id,
+ solo_participant = models_raid.RaidParticipant(
+ user_id=user_solo.id,
+ edition_id=active_edition.id,
+ status=RaidRegistrationStatus.draft,
+ situation=Situation.other,
+ is_minor=False,
)
- await add_object_to_db(participant)
+ await add_object_to_db(solo_participant)
- global team
- team = models_raid.RaidTeam(
+ main_team = models_raid.RaidTeam(
id=str(uuid.uuid4()),
- name="TestTeam",
- captain_id=simple_user.id,
- difficulty=None,
+ edition_id=active_edition.id,
+ name="MainTeam",
+ difficulty=Difficulty.sports,
+ meeting_place=MeetingPlace.centrale,
+ captain_id=user_captain.id,
+ second_id=user_second.id,
)
- await add_object_to_db(team)
-
- no_team_participant = models_raid.RaidParticipant(
- id=simple_user_without_team.id,
- firstname="NoTeam",
- name="NoTeam",
- birthday=datetime.date(2001, 1, 1),
- phone="0606060606",
- email="test@no_team.fr",
- )
- await add_object_to_db(no_team_participant)
-
- validated_team_participant_captain = models_raid.RaidParticipant(
- id=validated_team_captain.id,
- firstname="Validated",
- name="Captain",
- address="123 rue de la rue",
- birthday=datetime.date(2001, 1, 1),
- phone="0606060606",
- email="test@validated.fr",
- t_shirt_size=Size.M,
- bike_size=Size.M,
- attestation_on_honour=True,
- situation="centrale",
- payment=True,
- id_card_id=validated_document.id,
- medical_certificate_id=validated_document.id,
- student_card_id=validated_document.id,
- raid_rules_id=validated_document.id,
- parent_authorization_id=validated_document.id,
- )
-
- await add_object_to_db(validated_team_participant_captain)
-
- validated_team_participant_second = models_raid.RaidParticipant(
- id=validated_team_second.id,
- firstname="Validated",
- name="Second",
- address="123 rue de la rue",
- birthday=datetime.date(2001, 1, 1),
- phone="0606060606",
- email="test2@validated.fr",
- t_shirt_size=Size.M,
- bike_size=Size.M,
- attestation_on_honour=True,
- situation="centrale",
- payment=True,
- id_card_id=validated_document.id,
- medical_certificate_id=validated_document.id,
- student_card_id=validated_document.id,
- raid_rules_id=validated_document.id,
- parent_authorization_id=validated_document.id,
+ await add_object_to_db(main_team)
+
+ solo_team = models_raid.RaidTeam(
+ id=str(uuid.uuid4()),
+ edition_id=active_edition.id,
+ name="SoloTeam",
+ difficulty=None,
+ meeting_place=None,
+ captain_id=user_solo.id,
+ second_id=None,
)
+ await add_object_to_db(solo_team)
- await add_object_to_db(validated_team_participant_second)
- global validated_team
- validated_team = models_raid.RaidTeam(
- id=str(uuid.uuid4()),
- name="ValidatedTeam",
- difficulty=Difficulty.sports,
- meeting_place=MeetingPlace.centrale,
- captain_id=validated_team_captain.id,
- second_id=validated_team_second.id,
- file_id=str(uuid.uuid4()),
- )
-
- await add_object_to_db(validated_team)
-
- await Path("data/raid/").mkdir(parents=True, exist_ok=True)
- default_asset = "assets/pdf/default_PDF.pdf"
- expected_files = [
- "-1_ValidatedTeam_Captain_Validated.pdf",
- "-1_New Team_NoTeam_NoTeam.pdf",
- ]
- for path in expected_files:
- shutil.copyfile(
- default_asset,
- "data/raid/" + path,
- )
+# ---------------------------------------------------------------------------
+# Edition endpoints
+# ---------------------------------------------------------------------------
- await add_coredata_to_db(
- coredata_raid.RaidPrice(
- student_price=50,
- t_shirt_price=15,
- external_price=90,
- ),
+
+def test_get_active_edition(client: TestClient) -> None:
+ r = client.get(
+ "/raid/editions/active",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
+ assert r.status_code == 200
+ assert r.json()["id"] == str(active_edition.id)
-def test_get_participant_by_id(client: TestClient):
- response = client.get(
- f"/raid/participants/{simple_user.id}",
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_list_editions_requires_admin(client: TestClient) -> None:
+ r = client.get(
+ "/raid/editions",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 200
- assert response.json()["id"] == simple_user.id
+ assert r.status_code == 403
-def test_create_participant(client: TestClient):
- participant_data = {
- "firstname": "New",
- "name": "Participant",
- "birthday": "2000-01-01",
- "phone": "0123456789",
- "email": "new@participant.com",
- }
- response = client.post(
- "/raid/participants",
- json=participant_data,
- headers={"Authorization": f"Bearer {token_simple_without_participant}"},
+def test_list_editions_as_admin(client: TestClient) -> None:
+ r = client.get(
+ "/raid/editions",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 201
- assert response.json()["firstname"] == "New"
+ assert r.status_code == 200
+ assert any(e["id"] == str(active_edition.id) for e in r.json())
-def test_confirm_payment(client: TestClient):
- response = client.post(
- f"/raid/participant/{simple_user.id}/payment",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_create_and_delete_archive_edition(client: TestClient) -> None:
+ r = client.post(
+ "/raid/editions",
+ json={
+ "name": "Test Archive Edition",
+ "year": 2020,
+ "active": False,
+ "inscription_enabled": False,
+ },
+ headers={"Authorization": f"Bearer {token_admin}"},
+ )
+ assert r.status_code == 201
+ new_id = r.json()["id"]
+
+ d = client.delete(
+ f"/raid/editions/{new_id}",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 204
+ assert d.status_code == 204
-# Failing in batch, passing alone
-def test_confirm_t_shirt_payment(client: TestClient):
- response = client.post(
- f"/raid/participant/{simple_user.id}/t_shirt_payment",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_delete_edition_with_participants_rejected(client: TestClient) -> None:
+ r = client.delete(
+ f"/raid/editions/{active_edition.id}",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 204
+ assert r.status_code == 400
-def test_update_participant_success(client: TestClient):
- update_data = {
- "firstname": "UpdatedFirst",
- "name": "UpdatedLast",
- "birthday": "1995-01-01",
- "phone": "9876543210",
- "email": "updated@example.com",
- "t_shirt_size": "L",
- }
- response = client.patch(
- f"/raid/participants/{simple_user.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+# ---------------------------------------------------------------------------
+# Participants: state machine
+# ---------------------------------------------------------------------------
+
+
+def test_get_participant_self(client: TestClient) -> None:
+ r = client.get(
+ f"/raid/participants/{user_captain.id}",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 204
+ assert r.status_code == 200
+ body = r.json()
+ assert body["user_id"] == user_captain.id
+ assert body["status"] == "draft"
+ # CoreUser is embedded on the full read schema.
+ assert body["user"]["name"] == user_captain.name
-def test_update_participant_not_a_participant(client: TestClient):
- update_data = {"firstname": "UpdatedFirst"}
- response = client.patch(
- f"/raid/participants/{simple_user_without_participant.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_simple_without_participant}"},
+def test_get_participant_other_forbidden(client: TestClient) -> None:
+ r = client.get(
+ f"/raid/participants/{user_captain.id}",
+ headers={"Authorization": f"Bearer {token_solo}"},
)
- assert response.status_code == 403
- assert response.json()["detail"] == "You are not the participant."
+ assert r.status_code == 403
-def test_update_participant_not_same_team(client: TestClient):
- update_data = {"firstname": "UpdatedFirst"}
- response = client.patch(
- f"/raid/participants/{simple_user.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_simple_without_team}"},
+def test_get_participant_as_admin(client: TestClient) -> None:
+ r = client.get(
+ f"/raid/participants/{user_captain.id}",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 403
- assert response.json()["detail"] == "You are not the participant."
+ assert r.status_code == 200
-def test_update_participant_change_tshirt_size_before_payment(client: TestClient):
- update_data = {"t_shirt_size": "XL"}
- response = client.patch(
- f"/raid/participants/{simple_user.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_list_participants_admin_only(client: TestClient) -> None:
+ # A regular participant lacks manage_raid — the list is admin-only, which is
+ # what makes the Sentinel raid-import restricted to raid admins.
+ r = client.get(
+ "/raid/participants",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 204
+ assert r.status_code == 403
-def test_update_participant_change_tshirt_size_after_payment(client: TestClient):
- update_data = {"t_shirt_size": "S"}
- response = client.patch(
- f"/raid/participants/{simple_user.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_list_participants_as_admin(client: TestClient) -> None:
+ r = client.get(
+ "/raid/participants",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 204
+ assert r.status_code == 200
+ body = r.json()
+ assert any(p["user_id"] == user_captain.id for p in body)
+ # Full read schema embeds CoreUser — the importer maps name/email off it.
+ assert all("user" in p and "email" in p["user"] for p in body)
-def test_update_participant_invalid_document_id(client: TestClient):
- update_data = {"id_card_id": "invalid_id"}
- response = client.patch(
- f"/raid/participants/{simple_user.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_create_participant_missing_identity_400(client: TestClient) -> None:
+ r = client.post(
+ "/raid/participants",
+ headers={"Authorization": f"Bearer {token_no_raid}"},
)
- assert response.status_code == 404
- assert response.json()["detail"] == "Document id_card not found."
+ assert r.status_code == 400
+ assert "birthday or phone" in r.json()["detail"]
-def test_update_participant_invalid_security_file_id(client: TestClient):
- update_data = {"security_file_id": "invalid_id"}
- response = client.patch(
- f"/raid/participants/{simple_user.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_create_participant_success(client: TestClient) -> None:
+ r = client.post(
+ "/raid/participants",
+ headers={"Authorization": f"Bearer {token_no_profile}"},
)
- assert response.status_code == 404
- assert response.json()["detail"] == "Security_file not found."
+ assert r.status_code == 201
+ body = r.json()
+ assert body["user_id"] == user_no_profile.id
+ assert body["status"] == "draft"
+ assert body["edition_id"] == str(active_edition.id)
-def test_create_team(client: TestClient):
- team_data = {"name": "New Team"}
- response = client.post(
- "/raid/teams",
- json=team_data,
- headers={"Authorization": f"Bearer {token_simple_without_team}"},
+def test_create_participant_twice_rejected(client: TestClient) -> None:
+ r = client.post(
+ "/raid/participants",
+ headers={"Authorization": f"Bearer {token_no_profile}"},
)
- assert response.status_code == 201
- assert response.json()["name"] == "New Team"
+ assert r.status_code == 403
-def test_get_team_by_participant_id(client: TestClient):
- response = client.get(
- f"/raid/participants/{simple_user.id}/team",
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_update_participant_in_draft(client: TestClient) -> None:
+ r = client.patch(
+ f"/raid/participants/{user_captain.id}",
+ json={"address": "42 rue Example", "bike_size": "L"},
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 200
- assert "id" in response.json()
+ assert r.status_code == 204
-def test_get_all_teams(client: TestClient):
- response = client.get(
- "/raid/teams",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_update_participant_other_forbidden(client: TestClient) -> None:
+ r = client.patch(
+ f"/raid/participants/{user_captain.id}",
+ json={"address": "should fail"},
+ headers={"Authorization": f"Bearer {token_solo}"},
)
- assert response.status_code == 200
- assert isinstance(response.json(), list)
+ assert r.status_code == 403
-def test_get_team_by_id(client: TestClient):
- response = client.get(
- f"/raid/teams/{team.id}",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_update_participant_legacy_situation_string(client: TestClient) -> None:
+ # Grace-period coercion of `otherschool` -> Situation.otherSchool.
+ r = client.patch(
+ f"/raid/participants/{user_captain.id}",
+ json={"situation": "otherschool", "other_school": "ECP"},
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 200
- assert response.json()["id"] == team.id
+ assert r.status_code == 204
-def test_update_team(client: TestClient):
- update_data = {"name": "Updated Team"}
- response = client.patch(
- f"/raid/teams/{team.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_update_participant_invalid_document(client: TestClient) -> None:
+ r = client.patch(
+ f"/raid/participants/{user_captain.id}",
+ json={"id_card_id": "does-not-exist"},
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 204
+ assert r.status_code == 404
-def test_set_team_number(client: TestClient):
- update_data = {"name": "Updated Validated Team"}
- response = client.patch(
- f"/raid/teams/{validated_team.id}",
- json=update_data,
- headers={"Authorization": f"Bearer {token_validated_team_captain}"},
+def test_submit_without_attestation_400(client: TestClient) -> None:
+ r = client.post(
+ f"/raid/participants/{user_captain.id}/submit",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 204
+ assert r.status_code == 400
+ assert "Attestation" in r.json()["detail"]
-def test_upload_document(client: TestClient):
- file_content = b"test document content"
- files = {"file": ("test.pdf", file_content, "application/pdf")}
- response = client.post(
- "/raid/document/idCard",
- files=files,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_submit_without_documents_400(client: TestClient) -> None:
+ client.post(
+ f"/raid/participant/{user_captain.id}/honour",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 201
- assert "id" in response.json()
+ r = client.post(
+ f"/raid/participants/{user_captain.id}/submit",
+ headers={"Authorization": f"Bearer {token_captain}"},
+ )
+ assert r.status_code == 400
-def test_read_document_not_found(client: TestClient):
- document_id = "non_existent_document_id"
- response = client.get(
- f"/raid/document/{document_id}",
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_admin_validate_fails_before_prerequisites(client: TestClient) -> None:
+ r = client.patch(
+ f"/raid/participants/{user_captain.id}/validate",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 404
- assert response.json()["detail"] == "Document not found."
+ assert r.status_code == 400
-def test_read_document_participant_not_found(client: TestClient):
- # Create a test document without associating it with a participant
- test_file_content = b"orphan document content"
- files = {"file": ("orphan.pdf", test_file_content, "application/pdf")}
- upload_response = client.post(
- "/raid/document/idCard",
- files=files,
- headers={"Authorization": f"Bearer {token_raid_admin}"},
- )
- assert upload_response.status_code == 201
- document_id = upload_response.json()["id"]
-
- # Manually remove the participant association (this would typically be done in the database)
- # For the purpose of this test, we're simulating a scenario where the document exists but has no associated participant
-
- # Now try to read the document as a regular user
- response = client.get(
- f"/raid/document/{document_id}",
- headers={"Authorization": f"Bearer {token_simple}"},
- )
- assert response.status_code == 404
- assert response.json()["detail"] == "Participant owning the document not found."
-
-
-# requires a document to be added
-def test_validate_document(client: TestClient):
- document_id = "some_document_id"
- response = client.post(
- f"/raid/document/{document_id}/validate?validation=accepted",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
- )
- assert response.status_code == 204
+async def _prepare_full_validation_state() -> None:
+ """Promote captain + second to every prerequisite (except difficulty/meeting)."""
+ async with get_TestingSessionLocal()() as db:
+ docs = {}
+ for doc_type in (
+ DocumentType.idCard,
+ DocumentType.medicalCertificate,
+ DocumentType.raidRules,
+ DocumentType.studentCard,
+ ):
+ doc = models_raid.Document(
+ id=str(uuid.uuid4()),
+ edition_id=active_edition.id,
+ name=f"{doc_type.value}.pdf",
+ uploaded_at=datetime.datetime.now(tz=datetime.UTC).date(),
+ type=doc_type,
+ validation=DocumentValidation.accepted,
+ )
+ db.add(doc)
+ docs[doc_type] = doc
+ await db.flush()
-
-def test_set_security_file_success(client: TestClient):
- security_file_data = {
- "asthma": True,
- }
- response = client.post(
- f"/raid/security_file/?participant_id={simple_user.id}",
- json=security_file_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+ security = models_raid.SecurityFile(
+ id=str(uuid.uuid4()),
+ edition_id=active_edition.id,
+ allergy=None,
+ asthma=False,
+ intensive_care_unit=None,
+ intensive_care_unit_when=None,
+ ongoing_treatment=None,
+ sicknesses=None,
+ hospitalization=None,
+ surgical_operation=None,
+ trauma=None,
+ family=None,
+ emergency_person_firstname="Jane",
+ emergency_person_name="Doe",
+ emergency_person_phone="0600000000",
+ file_id=None,
+ )
+ db.add(security)
+ await db.flush()
+
+ for uid in (user_captain.id, user_second.id):
+ await db.execute(
+ update(models_raid.RaidParticipant)
+ .where(
+ models_raid.RaidParticipant.user_id == uid,
+ models_raid.RaidParticipant.edition_id == active_edition.id,
+ )
+ .values(
+ id_card_id=docs[DocumentType.idCard].id,
+ medical_certificate_id=docs[DocumentType.medicalCertificate].id,
+ raid_rules_id=docs[DocumentType.raidRules].id,
+ student_card_id=docs[DocumentType.studentCard].id,
+ security_file_id=security.id,
+ attestation_on_honour=True,
+ payment=True,
+ t_shirt_payment=True,
+ situation=Situation.centrale,
+ ),
+ )
+ await db.commit()
+
+
+def test_admin_validate_full_happy_path(client: TestClient) -> None:
+ asyncio.get_event_loop().run_until_complete(_prepare_full_validation_state())
+
+ r = client.patch(
+ f"/raid/participants/{user_captain.id}/validate",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 201
- assert "id" in response.json()
-
+ assert r.status_code == 204, r.json()
-def test_set_security_file_not_in_same_team(client: TestClient):
- security_file_data = {
- "asthma": False,
- "emergency_contact_name": "Another Contact",
- "emergency_contact_phone": "1234567890",
- }
- response = client.post(
- f"/raid/security_file/?participant_id={simple_user_without_team.id}",
- json=security_file_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+ r = client.get(
+ f"/raid/participants/{user_captain.id}",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 403
- assert response.json()["detail"] == "You are not the participant."
+ assert r.json()["status"] == "validated"
-def test_set_security_file_participant_not_exist(client: TestClient):
- security_file_data = {
- "asthma": True,
- "emergency_contact_name": "Non-existent Contact",
- "emergency_contact_phone": "9876543210",
- }
- non_existent_id = "non_existent_id"
- response = client.post(
- f"/raid/security_file/?participant_id={non_existent_id}",
- json=security_file_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_non_admin_update_blocked_after_validation(client: TestClient) -> None:
+ r = client.patch(
+ f"/raid/participants/{user_captain.id}",
+ json={"address": "new addr"},
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 403
- assert response.json()["detail"] == "You are not the participant."
+ assert r.status_code == 400
-def test_set_security_file_update_existing(client: TestClient):
- # First, create an initial security file
- initial_data = {
- "asthma": False,
- "emergency_contact_name": "Initial Contact",
- "emergency_contact_phone": "1111111111",
- }
- initial_response = client.post(
- f"/raid/security_file/?participant_id={simple_user.id}",
- json=initial_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_admin_update_still_allowed(client: TestClient) -> None:
+ r = client.patch(
+ f"/raid/participants/{user_captain.id}",
+ json={"diet": "veggie"},
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert initial_response.status_code == 201
+ assert r.status_code == 204
+
- # Now, update the security file
- updated_data = {
- "asthma": True,
- "emergency_contact_name": "Updated Contact",
- "emergency_contact_phone": "2222222222",
- }
- update_response = client.post(
- f"/raid/security_file/?participant_id={simple_user.id}",
- json=updated_data,
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_self_reopen_validated_403(client: TestClient) -> None:
+ r = client.post(
+ f"/raid/participants/{user_captain.id}/reopen",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert update_response.status_code == 201
- assert update_response.json()["id"] != initial_response.json()["id"]
+ assert r.status_code == 403
-def test_validate_attestation_on_honour(client: TestClient):
- response = client.post(
- f"/raid/participant/{simple_user.id}/honour",
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_admin_reopen_to_draft(client: TestClient) -> None:
+ r = client.post(
+ f"/raid/participants/{user_captain.id}/reopen",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 204
+ assert r.status_code == 204
+ r2 = client.get(
+ f"/raid/participants/{user_captain.id}",
+ headers={"Authorization": f"Bearer {token_admin}"},
+ )
+ assert r2.json()["status"] == "draft"
-# Failing in batch, passing alone
-def test_join_team(client: TestClient):
- # Create an invite token first
- create_token_response = client.post(
- f"/raid/teams/{team.id}/invite",
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_cancel_by_self(client: TestClient) -> None:
+ r = client.patch(
+ f"/raid/participants/{user_solo.id}/cancel",
+ headers={"Authorization": f"Bearer {token_solo}"},
)
- assert create_token_response.status_code == 201
- token = create_token_response.json()["token"]
-
- # Now use the created token to join the team
- response = client.post(
- f"/raid/teams/join/{token}",
- headers={"Authorization": f"Bearer {token_simple_without_team}"},
+ assert r.status_code == 204
+ r2 = client.get(
+ f"/raid/participants/{user_solo.id}",
+ headers={"Authorization": f"Bearer {token_solo}"},
)
- assert response.status_code == 204
+ assert r2.json()["status"] == "cancelled"
-def test_kick_team_member(client: TestClient):
- response = client.post(
- f"/raid/teams/{team.id}/kick/{simple_user_without_team.id}",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
- )
- assert response.status_code == 201
+# ---------------------------------------------------------------------------
+# Teams
+# ---------------------------------------------------------------------------
-# Failing in batch, passing alone
-def test_create_invite_token(client: TestClient):
- response = client.post(
- f"/raid/teams/{team.id}/invite",
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_list_teams_requires_admin(client: TestClient) -> None:
+ r = client.get(
+ "/raid/teams",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 201
- assert "token" in response.json()
-
+ assert r.status_code == 403
-# Fail due to pdf writing error
-def test_merge_teams(client: TestClient):
- # Create two teams for testing
- team1_id = team.id
- team2_response = client.post(
+def test_list_teams_as_admin(client: TestClient) -> None:
+ r = client.get(
"/raid/teams",
- json={"name": "Team 2"},
- headers={"Authorization": f"Bearer {token_simple_without_participant}"},
- )
- assert team2_response.status_code == 201
- team2_id = team2_response.json()["id"]
- response = client.post(
- f"/raid/teams/merge?team1_id={team1_id}&team2_id={team2_id}",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 201
+ assert r.status_code == 200
+ assert isinstance(r.json(), list)
+ assert len(r.json()) >= 2
-def test_get_raid_information(client: TestClient):
- response = client.get(
- "/raid/information",
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_get_team_by_participant(client: TestClient) -> None:
+ r = client.get(
+ f"/raid/participants/{user_captain.id}/team",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 200
+ assert r.status_code == 200
+ assert r.json()["captain"]["user_id"] == user_captain.id
-def test_update_raid_information(client: TestClient):
- raid_info = {
- "raid_start_date": "2023-09-01",
- }
- response = client.patch(
- "/raid/information",
- json=raid_info,
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_update_team_by_captain(client: TestClient) -> None:
+ team = client.get(
+ f"/raid/participants/{user_captain.id}/team",
+ headers={"Authorization": f"Bearer {token_captain}"},
+ ).json()
+ r = client.patch(
+ f"/raid/teams/{team['id']}",
+ json={"name": "MainTeam-Renamed"},
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 204
+ assert r.status_code == 204
+
+
+# ---------------------------------------------------------------------------
+# Documents
+# ---------------------------------------------------------------------------
-def test_get_raid_price(client: TestClient):
- response = client.get(
- "/raid/price",
- headers={"Authorization": f"Bearer {token_simple}"},
+def test_upload_document(client: TestClient) -> None:
+ r = client.post(
+ "/raid/document/idCard",
+ files={"file": ("idCard.pdf", b"blob", "application/pdf")},
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 200
+ assert r.status_code == 201
-def test_update_raid_price(client: TestClient):
- price_data = {"student_price": 50, "t_shirt_price": 15, "external_price": 90}
- response = client.patch(
- "/raid/price",
- json=price_data,
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_validate_document_requires_admin(client: TestClient) -> None:
+ r = client.post(
+ f"/raid/document/{doc_pending.id}/validate?validation=accepted",
+ headers={"Authorization": f"Bearer {token_captain}"},
)
- assert response.status_code == 204
+ assert r.status_code == 403
-def test_delete_team(client: TestClient):
- response = client.delete(
- f"/raid/teams/{team.id}",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_validate_document_as_admin(client: TestClient) -> None:
+ r = client.post(
+ f"/raid/document/{doc_pending.id}/validate?validation=accepted",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 204
+ assert r.status_code == 204
-## Test for pdf writer
+# ---------------------------------------------------------------------------
+# Payment
+# ---------------------------------------------------------------------------
-@pytest.fixture
-def mock_team():
- return Mock(
- spec=RaidTeam,
- name="Test Team",
- number=1,
- captain=Mock(
- spec=RaidParticipant,
- name="Doe",
- firstname="John",
- birthday=datetime.datetime(1990, 1, 1, tzinfo=datetime.UTC),
- phone="0606060606",
- email="test@email.fr",
- id=str(uuid.uuid4()),
- bike_size=None,
- t_shirt_size=None,
- situation=None,
- validation_progress=0.1,
- payment=False,
- t_shirt_payment=False,
- number_of_document=1,
- number_of_validated_document=0,
- address=None,
- other_school=None,
- company=None,
- diet=None,
- id_card=None,
- medical_certificate=None,
- security_file=None,
- student_card=None,
- raid_rules=None,
- parent_authorization=None,
- attestation_on_honour=False,
- is_minor=False,
- ),
- validation_progress=10,
+def test_payment_url_requires_participant(client: TestClient) -> None:
+ r = client.get(
+ "/raid/pay",
+ headers={"Authorization": f"Bearer {token_no_raid}"},
)
+ assert r.status_code == 403
-@pytest.fixture
-def mock_security_file():
- return Mock(spec=SecurityFile, allergy="None", asthma=False)
+def test_confirm_payment_requires_admin(client: TestClient) -> None:
+ r = client.post(
+ f"/raid/participant/{user_second.id}/payment",
+ headers={"Authorization": f"Bearer {token_captain}"},
+ )
+ assert r.status_code == 403
-@pytest.fixture
-def mock_participant():
- return Mock(
- spec=RaidParticipant,
- name="Doe",
- firstname="John",
- birthday=datetime.datetime(1990, 1, 1, tzinfo=datetime.UTC),
- phone="0606060606",
- email="test@email.fr",
- id=str(uuid.uuid4()),
- bike_size=None,
- t_shirt_size=None,
- situation=None,
- validation_progress=0.1,
- payment=False,
- t_shirt_payment=False,
- number_of_document=1,
- number_of_validated_document=0,
- address=None,
- other_school=None,
- company=None,
- diet=None,
- id_card=None,
- medical_certificate=None,
- student_card=None,
- raid_rules=None,
- parent_authorization=None,
- attestation_on_honour=False,
- is_minor=False,
- security_file=Mock(spec=SecurityFile, allergy="None", asthma=False),
+def test_confirm_payment_as_admin(client: TestClient) -> None:
+ r = client.post(
+ f"/raid/participant/{user_second.id}/payment",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
+ assert r.status_code == 204
-async def test_set_team_number_utility_empty_database(
- mocker: MockerFixture,
-):
- """Test the set_team_number utility with an empty database (no existing teams)"""
- # Create mock objects
- mock_db = mocker.AsyncMock()
- mock_team = mocker.Mock(
- spec=RaidTeam,
- id=str(uuid.uuid4()),
- difficulty=Difficulty.sports,
+def test_confirm_tshirt_payment_requires_size(client: TestClient) -> None:
+ r = client.post(
+ f"/raid/participant/{user_no_profile.id}/t_shirt_payment",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
+ # user_no_profile has no t_shirt_size set.
+ assert r.status_code == 400
- # Mock the get_number_of_team_by_difficulty function to return 0
- mocker.patch(
- "app.modules.raid.cruds_raid.get_number_of_team_by_difficulty",
- return_value=0,
- )
- # Mock the update_team function
- mock_update_team = mocker.patch("app.modules.raid.cruds_raid.update_team")
+# ---------------------------------------------------------------------------
+# Volunteers
+# ---------------------------------------------------------------------------
- # Call the function
- await set_team_number(mock_team, mock_db)
+def test_participant_cannot_register_as_volunteer(client: TestClient) -> None:
+ r = client.post(
+ "/raid/volunteers",
+ json={},
+ headers={"Authorization": f"Bearer {token_captain}"},
+ )
+ assert r.status_code == 400
+
- # Assert update_team was called with correct parameters
- mock_update_team.assert_called_once()
- args, _ = mock_update_team.call_args
- assert args[0] == mock_team.id
- assert args[1].number == 101 # 100 (sports separator) + 1
+def test_create_volunteer(client: TestClient) -> None:
+ r = client.post(
+ "/raid/volunteers",
+ json={
+ "diet": "veggie",
+ "emergency_person_name": "Jane Doe",
+ "emergency_person_phone": "+33611111111",
+ "has_car": True,
+ "car_seats": 4,
+ "is_parcours_helper": True,
+ },
+ headers={"Authorization": f"Bearer {token_volunteer}"},
+ )
+ assert r.status_code == 201
+ body = r.json()
+ assert body["validated"] is False
+ assert body["cancelled"] is False
+ assert body["has_car"] is True
+ assert body["car_seats"] == 4
+ assert body["is_parcours_helper"] is True
-async def test_set_team_number_utility_existing_teams(
- mocker: MockerFixture,
-):
- """Test the set_team_number utility with existing teams"""
- # Create mock objects
- mock_db = mocker.AsyncMock()
- mock_team = mocker.Mock(
- spec=RaidTeam,
- id=str(uuid.uuid4()),
- difficulty=Difficulty.expert,
+def test_create_volunteer_twice_rejected(client: TestClient) -> None:
+ r = client.post(
+ "/raid/volunteers",
+ json={},
+ headers={"Authorization": f"Bearer {token_volunteer}"},
)
+ assert r.status_code == 403
+
- # Mock the get_number_of_team_by_difficulty function to return existing team numbers
- mocker.patch(
- "app.modules.raid.cruds_raid.get_number_of_team_by_difficulty",
- return_value=220,
+def test_volunteer_cannot_become_participant(client: TestClient) -> None:
+ r = client.post(
+ "/raid/participants",
+ headers={"Authorization": f"Bearer {token_volunteer}"},
)
+ assert r.status_code == 400
- # Mock the update_team function
- mock_update_team = mocker.patch("app.modules.raid.cruds_raid.update_team")
- # Call the function
+def test_get_my_volunteer(client: TestClient) -> None:
+ r = client.get(
+ "/raid/volunteers/me",
+ headers={"Authorization": f"Bearer {token_volunteer}"},
+ )
+ assert r.status_code == 200
+ assert r.json()["user_id"] == user_volunteer.id
- await set_team_number(mock_team, mock_db)
- # Assert update_team was called with correct parameters
- mock_update_team.assert_called_once()
- args, _ = mock_update_team.call_args
- assert args[0] == mock_team.id
- assert args[1].number == 221 # 220 + 1
+def test_list_volunteers_admin_only(client: TestClient) -> None:
+ r = client.get(
+ "/raid/volunteers",
+ headers={"Authorization": f"Bearer {token_volunteer}"},
+ )
+ assert r.status_code == 403
-async def test_set_team_number_utility_no_difficulty(
- mocker: MockerFixture,
-):
- """Test the set_team_number utility with a team without difficulty"""
- # Create mock objects
- mock_db = mocker.AsyncMock()
- mock_team = mocker.Mock(
- spec=RaidTeam,
- id=str(uuid.uuid4()),
- difficulty=None,
+def test_list_volunteers_as_admin(client: TestClient) -> None:
+ r = client.get(
+ "/raid/volunteers",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
+ assert r.status_code == 200
+ assert any(v["user_id"] == user_volunteer.id for v in r.json())
- # Mock the update_team function
- mock_update_team = mocker.patch("app.modules.raid.cruds_raid.update_team")
- # Call the function
+def test_update_volunteer_self(client: TestClient) -> None:
+ r = client.patch(
+ f"/raid/volunteers/{user_volunteer.id}",
+ json={"diet": "noodles only"},
+ headers={"Authorization": f"Bearer {token_volunteer}"},
+ )
+ assert r.status_code == 204
- await set_team_number(mock_team, mock_db)
- # Assert update_team was not called
- mock_update_team.assert_not_called()
+def test_validate_volunteer_fails_with_car_but_no_seats(
+ client: TestClient,
+) -> None:
+ async def _break_car():
+ async with get_TestingSessionLocal()() as db:
+ await db.execute(
+ update(models_raid.RaidVolunteer)
+ .where(
+ models_raid.RaidVolunteer.user_id == user_volunteer.id,
+ models_raid.RaidVolunteer.edition_id == active_edition.id,
+ )
+ .values(has_car=True, car_seats=None),
+ )
+ await db.commit()
+ asyncio.get_event_loop().run_until_complete(_break_car())
-async def test_set_team_number_utility_discovery_difficulty(
- mocker: MockerFixture,
-):
- """Test the set_team_number utility with discovery difficulty"""
- # Create mock objects
- mock_db = mocker.AsyncMock()
- mock_team = mocker.Mock(
- spec=RaidTeam,
- id=str(uuid.uuid4()),
- difficulty=Difficulty.discovery,
+ r = client.patch(
+ f"/raid/volunteers/{user_volunteer.id}/validate",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
+ assert r.status_code == 400
+ assert "car_seats" in r.json()["detail"]
- # Mock the get_number_of_team_by_difficulty function
- mocker.patch(
- "app.modules.raid.cruds_raid.get_number_of_team_by_difficulty",
- return_value=5,
- )
- # Mock the update_team function
- mock_update_team = mocker.patch("app.modules.raid.cruds_raid.update_team")
- # Call the function
+def test_validate_volunteer_success(client: TestClient) -> None:
+ async def _restore():
+ async with get_TestingSessionLocal()() as db:
+ await db.execute(
+ update(models_raid.RaidVolunteer)
+ .where(
+ models_raid.RaidVolunteer.user_id == user_volunteer.id,
+ models_raid.RaidVolunteer.edition_id == active_edition.id,
+ )
+ .values(has_car=True, car_seats=4),
+ )
+ await db.commit()
- await set_team_number(mock_team, mock_db)
+ asyncio.get_event_loop().run_until_complete(_restore())
- # Assert update_team was called with correct parameters
- mock_update_team.assert_called_once()
- args, _ = mock_update_team.call_args
- assert args[0] == mock_team.id
- assert args[1].number == 6 # discovery (0) + 5 + 1
+ r = client.patch(
+ f"/raid/volunteers/{user_volunteer.id}/validate",
+ headers={"Authorization": f"Bearer {token_admin}"},
+ )
+ assert r.status_code == 204
-@pytest.mark.parametrize(
- ("participant_kwargs", "expected_price"),
- [
- # Student price only
- (
- {
- "payment": False,
- "t_shirt_size": None,
- "t_shirt_payment": False,
- "situation": "centrale",
- "student_card_id": str(uuid.uuid4()),
- },
- 50,
- ),
- # Student price only
- (
- {
- "payment": False,
- "t_shirt_size": None,
- "t_shirt_payment": False,
- "situation": "otherschool : Some School",
- "student_card_id": str(uuid.uuid4()),
- },
- 50,
- ),
- # Student price only but without student card
- (
- {
- "payment": False,
- "t_shirt_size": None,
- "t_shirt_payment": False,
- "situation": "centrale",
- "student_card_id": None,
- },
- 90,
- ),
- # Student price only but without student card
- (
- {
- "payment": False,
- "t_shirt_size": None,
- "t_shirt_payment": False,
- "situation": "otherschool : Some School",
- "student_card_id": None,
- },
- 90,
- ),
- # External price only
- (
- {
- "payment": False,
- "t_shirt_size": None,
- "t_shirt_payment": False,
- "situation": "other",
- "student_card_id": None,
- },
- 90,
- ),
- # Student price + T-shirt
- (
- {
- "payment": False,
- "t_shirt_size": Size.L,
- "t_shirt_payment": False,
- "situation": "centrale",
- "student_card_id": str(uuid.uuid4()),
- },
- 65,
- ),
- # External price + T-shirt
- (
- {
- "payment": False,
- "t_shirt_size": Size.L,
- "t_shirt_payment": False,
- "situation": "other",
- "student_card_id": None,
- },
- 105,
- ),
- # Already paid, no T-shirt
- (
- {
- "payment": True,
- "t_shirt_size": None,
- "t_shirt_payment": False,
- "situation": "centrale",
- "student_card_id": str(uuid.uuid4()),
- },
- 0,
- ),
- # Already paid, T-shirt not paid
- (
- {
- "payment": True,
- "t_shirt_size": Size.L,
- "t_shirt_payment": False,
- "situation": "centrale",
- "student_card_id": str(uuid.uuid4()),
- },
- 15,
- ),
- # Already paid, T-shirt already paid
- (
- {
- "payment": True,
- "t_shirt_size": Size.L,
- "t_shirt_payment": True,
- "situation": "centrale",
- "student_card_id": str(uuid.uuid4()),
- },
- 0,
- ),
- # No student card, T-shirt already paid
- (
- {
- "payment": True,
- "t_shirt_size": Size.L,
- "t_shirt_payment": True,
- "situation": "otherschool : Some School",
- "student_card_id": str(uuid.uuid4()),
- },
- 0,
- ),
- ],
-)
-def test_calculate_raid_payment_price_only(participant_kwargs, expected_price):
- raid_prices = coredata_raid.RaidPrice(
- student_price=50,
- t_shirt_price=15,
- external_price=90,
+def test_delete_validated_volunteer_self_forbidden(client: TestClient) -> None:
+ r = client.delete(
+ f"/raid/volunteers/{user_volunteer.id}",
+ headers={"Authorization": f"Bearer {token_volunteer}"},
)
- participant = RaidParticipant(
- id=str(uuid.uuid4()),
- name="Name",
- firstname="Firstname",
- birthday=datetime.date(2000, 1, 1),
- phone="0123456789",
- email="name@example.com",
- payment=participant_kwargs["payment"],
- t_shirt_size=participant_kwargs["t_shirt_size"],
- t_shirt_payment=participant_kwargs["t_shirt_payment"],
- situation=participant_kwargs["situation"],
- student_card_id=participant_kwargs["student_card_id"],
- )
- price, _ = calculate_raid_payment(participant, raid_prices)
- assert price == expected_price
+ assert r.status_code == 403
-def test_download_security_files_zip(client: TestClient):
- response = client.get(
- "/raid/security_files_zip",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_delete_validated_volunteer_as_admin(client: TestClient) -> None:
+ r = client.delete(
+ f"/raid/volunteers/{user_volunteer.id}",
+ headers={"Authorization": f"Bearer {token_admin}"},
)
- assert response.status_code == 200
+ assert r.status_code == 204
-def test_download_team_files_zip(client: TestClient):
- response = client.get(
- "/raid/team_files_zip",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
+def test_get_volunteer_me_after_delete(client: TestClient) -> None:
+ r = client.get(
+ "/raid/volunteers/me",
+ headers={"Authorization": f"Bearer {token_volunteer}"},
)
- assert response.status_code == 200
+ assert r.status_code == 404
-def test_delete_all_teams(client: TestClient):
- response = client.delete(
- "/raid/teams",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
- )
- assert response.status_code == 204
+# ---------------------------------------------------------------------------
+# Raw CRUD integration tests (edition-aware)
+# ---------------------------------------------------------------------------
- response = client.get(
- "/raid/teams",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
- )
- assert response.status_code == 200
- assert len(response.json()) == 0
+@pytest.mark.asyncio
+async def test_get_active_edition_crud() -> None:
+ async with get_TestingSessionLocal()() as db:
+ edition = await cruds_raid.get_active_edition(db)
+ assert edition is not None
+ assert edition.id == active_edition.id
-def test_download_security_files_zip_with_no_teams(client: TestClient):
- response = client.get(
- "/raid/security_files_zip",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
- )
- assert response.status_code == 400
+@pytest.mark.asyncio
+async def test_get_all_participants_scoped_by_edition() -> None:
+ async with get_TestingSessionLocal()() as db:
+ all_here = await cruds_raid.get_all_participants(active_edition.id, db)
+ assert len(all_here) >= 3
+ drafts = await cruds_raid.get_all_participants(
+ active_edition.id,
+ db,
+ status=RaidRegistrationStatus.draft,
+ )
+ assert all(p.status == RaidRegistrationStatus.draft for p in drafts)
-def test_download_team_files_zip_with_no_teams(client: TestClient):
- response = client.get(
- "/raid/team_files_zip",
- headers={"Authorization": f"Bearer {token_raid_admin}"},
- )
- assert response.status_code == 400
+
+@pytest.mark.asyncio
+async def test_is_user_a_participant_true_and_false() -> None:
+ async with get_TestingSessionLocal()() as db:
+ assert await cruds_raid.is_user_a_participant(
+ user_second.id,
+ active_edition.id,
+ db,
+ )
+ assert not await cruds_raid.is_user_a_participant(
+ user_no_raid.id,
+ active_edition.id,
+ db,
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_team_by_participant_id_finds_both_roles() -> None:
+ async with get_TestingSessionLocal()() as db:
+ captain_team = await cruds_raid.get_team_by_participant_id(
+ user_captain.id,
+ active_edition.id,
+ db,
+ )
+ second_team = await cruds_raid.get_team_by_participant_id(
+ user_second.id,
+ active_edition.id,
+ db,
+ )
+ assert captain_team is not None
+ assert second_team is not None
+ assert captain_team.id == second_team.id
+
+
+@pytest.mark.asyncio
+async def test_get_number_of_teams_counts() -> None:
+ async with get_TestingSessionLocal()() as db:
+ n = await cruds_raid.get_number_of_teams(active_edition.id, db)
+ assert n >= 2
+
+
+@pytest.mark.asyncio
+async def test_volunteer_crud_roundtrip() -> None:
+ user = await create_user_with_groups([])
+ await _set_user_identity(user.id, "+33600000001", datetime.date(1998, 1, 1))
+
+ async with get_TestingSessionLocal()() as db:
+ v = schemas_raid.RaidVolunteerCreate(
+ user_id=user.id,
+ edition_id=active_edition.id,
+ created_at=datetime.datetime.now(tz=datetime.UTC),
+ validated=False,
+ cancelled=False,
+ )
+ await cruds_raid.create_volunteer(v, db)
+ await db.commit()
+ async with get_TestingSessionLocal()() as db:
+ got = await cruds_raid.get_volunteer_by_user_id(
+ user.id,
+ active_edition.id,
+ db,
+ )
+ assert got is not None
+ assert got.validated is False
+
+ all_v = await cruds_raid.get_all_volunteers_by_edition(
+ active_edition.id,
+ db,
+ )
+ assert any(x.user_id == user.id for x in all_v)
+
+ validated = await cruds_raid.get_all_volunteers_by_edition(
+ active_edition.id,
+ db,
+ validated=True,
+ )
+ assert not any(x.user_id == user.id for x in validated)
+
+ await cruds_raid.update_volunteer_validation(
+ user.id,
+ active_edition.id,
+ True,
+ db,
+ )
+ await db.commit()
+
+ async with get_TestingSessionLocal()() as db:
+ re_read = await cruds_raid.get_volunteer_by_user_id(
+ user.id,
+ active_edition.id,
+ db,
+ )
+ assert re_read is not None
+ assert re_read.validated is True
+
+
+@pytest.mark.asyncio
+async def test_edition_crud_create_read_delete() -> None:
+ async with get_TestingSessionLocal()() as db:
+ new_edition = schemas_raid.RaidEdition(
+ id=uuid.uuid4(),
+ year=2019,
+ name="Legacy",
+ start_date=None,
+ end_date=None,
+ registering_end_date=None,
+ active=False,
+ inscription_enabled=False,
+ )
+ await cruds_raid.create_edition(new_edition, db)
+ await db.commit()
+ async with get_TestingSessionLocal()() as db:
+ all_editions = await cruds_raid.get_all_editions(db)
+ assert any(e.id == new_edition.id for e in all_editions)
+ await cruds_raid.delete_edition(new_edition.id, db)
+ await db.commit()