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()