From af76ccf93ef5240c8686a109fefc76dc11574213 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:03:05 +0200 Subject: [PATCH 01/26] feat(raid): add RaidRegistrationStatus enum for participant state machine Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/raid_type.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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" From d4865c5a24dce4a3f1b6fec326103693d00aeb61 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:03:14 +0200 Subject: [PATCH 02/26] feat(config): add phone and birthday fields to UserDemoFactoryConfig Co-Authored-By: Claude Opus 4.6 (1M context) --- app/core/users/factory_users.py | 4 ++-- app/core/utils/config.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) 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): From 8e91e0a9f55d350376a9322056298c9b7c9e6119 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:03:24 +0200 Subject: [PATCH 03/26] feat(raid): add migration for edition scoping and registration state machine Co-Authored-By: Claude Opus 4.6 (1M context) --- .../versions/59-raid_editions_and_state.py | 452 ++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 migrations/versions/59-raid_editions_and_state.py diff --git a/migrations/versions/59-raid_editions_and_state.py b/migrations/versions/59-raid_editions_and_state.py new file mode 100644 index 0000000000..6b46b5f92a --- /dev/null +++ b/migrations/versions/59-raid_editions_and_state.py @@ -0,0 +1,452 @@ +"""raid_editions_and_state + +Create Date: 2026-04-21 00:00:00.000000 +""" + +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 = "e58ffcd6b9eb" +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" # noqa: N815 + corporatePartner = "corporatePartner" # noqa: N815 + other = "other" + + +DEFAULT_EDITION_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") + + +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: + import json + + 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"), + ): + try: + op.drop_constraint(fk_name, table, type_="foreignkey") + except Exception: # noqa: BLE001 - legacy FK names vary + pass + + op.alter_column("raid_participant", "id", new_column_name="user_id") + 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. + try: + op.drop_constraint("raid_participant_pkey", "raid_participant", type_="primary") + except Exception: # noqa: BLE001 + pass + 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", # noqa: S608 + ).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", + ) + op.create_foreign_key( + "raid_participant_checkout_participant_id_fkey", + "raid_participant_checkout", + "raid_participant", + ["participant_id"], + ["user_id"], + ) + + 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") + op.alter_column("raid_participant", "user_id", new_column_name="id") + 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"], + ) + + 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: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass From a6320549c694a841918104843d90594a45a47285 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:03:29 +0200 Subject: [PATCH 04/26] feat(raid): add migration for volunteer registration table Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/versions/60-raid_volunteers.py | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 migrations/versions/60-raid_volunteers.py diff --git a/migrations/versions/60-raid_volunteers.py b/migrations/versions/60-raid_volunteers.py new file mode 100644 index 0000000000..2062fa166b --- /dev/null +++ b/migrations/versions/60-raid_volunteers.py @@ -0,0 +1,80 @@ +"""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 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: + 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("diet", sa.String(), nullable=True), + sa.Column("allergy", 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 From db97d30fb723f5b83e92180d8450a595d3903120 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:03:36 +0200 Subject: [PATCH 05/26] feat(raid): rewrite models with edition scoping, state machine, and volunteers Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/models_raid.py | 231 ++++++++++++++------------------ 1 file changed, 98 insertions(+), 133 deletions(-) diff --git a/app/modules/raid/models_raid.py b/app/modules/raid/models_raid.py index fceff2d839..e0d825e005 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 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,39 @@ 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", + ), + ) class InviteToken(Base): @@ -293,6 +222,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 +233,42 @@ 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) + 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, + ) From 61bc941b0bd3e30e5fe6d8db1b467f8691a9b9e6 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:03:44 +0200 Subject: [PATCH 06/26] feat(raid): rewrite schemas for edition-scoped participants and volunteers Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/schemas_raid.py | 241 ++++++++++++++++++++++++++----- 1 file changed, 204 insertions(+), 37 deletions(-) diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 043b52d1e9..a5e99241a3 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -1,12 +1,22 @@ -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, ) @@ -54,50 +64,75 @@ class SecurityFile(SecurityFileBase): 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 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: Document | None = None + medical_certificate: Document | None = None + security_file: SecurityFile | None = None student_card: Document | None = None raid_rules: Document | 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: + from app.modules.raid.utils.validation_checker import ( + compute_participant_progress, + ) + + return compute_participant_progress(self) + + @computed_field # type: ignore[prop-decorator] + @property + def number_of_document(self) -> int: + from app.modules.raid.utils.validation_checker import ( + count_total_required_documents, + ) + + return count_total_required_documents(self) + + @computed_field # type: ignore[prop-decorator] + @property + def number_of_validated_document(self) -> int: + from app.modules.raid.utils.validation_checker import ( + count_accepted_documents, + ) + + 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 +144,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 +177,53 @@ class RaidTeamBase(BaseModel): class RaidTeamPreview(RaidTeamBase): id: str - number: int | None + edition_id: UUID + number: int | None = None captain: RaidParticipantPreview - second: RaidParticipantPreview | None - difficulty: Difficulty | None - meeting_place: MeetingPlace | None - validation_progress: float + 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: + from app.modules.raid.utils.validation_checker import compute_team_progress + + 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: RaidParticipant - second: RaidParticipant | None - difficulty: Difficulty | None - meeting_place: MeetingPlace | None - validation_progress: float - file_id: str | 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 RaidTeamUpdate(BaseModel): @@ -162,5 +253,81 @@ class PaymentUrl(BaseModel): class RaidParticipantCheckout(BaseModel): - participant_id: str + participant_user_id: str + edition_id: UUID checkout_id: str + + +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): + diet: str | None = None + allergy: 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 + + @model_validator(mode="after") + def _check_car_seats_consistency(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 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): + diet: str | None = None + allergy: 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 From 0bede2cbc45b414fb034a20afac92046a278051f Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:03:54 +0200 Subject: [PATCH 07/26] feat(raid): rewrite CRUDs with edition scoping and volunteer operations Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/cruds_raid.py | 500 +++++++++++++++++++++++++-------- 1 file changed, 378 insertions(+), 122 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index 7a464befd4..2013cc953f 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -1,12 +1,17 @@ from collections.abc import Sequence 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( @@ -19,105 +24,147 @@ async def create_participant( async def get_all_participants( + edition_id: UUID, db: AsyncSession, + status: RaidRegistrationStatus | None = None, ) -> 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("*"), - ), + 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 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: dict, db: AsyncSession, ) -> None: - query = ( + if not values: + 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), ) - - 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: 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() async def get_all_teams( + edition_id: UUID, 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("*"), - ), + select(models_raid.RaidTeam) + .where(models_raid.RaidTeam.edition_id == edition_id) + .options(selectinload("*")), ) return teams.scalars().all() async def get_all_validated_teams( + edition_id: UUID, 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("*"), - ), + """Validated = captain AND second both have status=validated.""" + Captain = models_raid.RaidParticipant.__table__.alias("captain_p") # noqa: N806 + Second = models_raid.RaidParticipant.__table__.alias("second_p") # noqa: N806 + 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 teams.scalars().all() async def get_team_by_id( @@ -127,10 +174,7 @@ async def get_team_by_id( 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() @@ -148,10 +192,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 +230,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 +268,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,9 +290,14 @@ 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() @@ -285,13 +349,17 @@ 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() @@ -307,14 +375,18 @@ async def create_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() @@ -354,6 +426,7 @@ async def get_user_by_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, ), ), ) @@ -391,65 +464,83 @@ async def mark_document_as_newly_updated( .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: 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() 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( @@ -470,7 +561,6 @@ async def create_invite_token( ) -> models_raid.InviteToken: db.add(invite) await db.flush() - return invite @@ -506,35 +596,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,30 +609,40 @@ 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. + """ + Captain = models_raid.RaidParticipant.__table__.alias("captain_p") # noqa: N806 + Second = models_raid.RaidParticipant.__table__.alias("second_p") # noqa: N806 + 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( @@ -584,7 +655,6 @@ async def create_participant_checkout( async def get_participant_checkout_by_checkout_id( - # TODO: use UUID checkout_id: str, db: AsyncSession, ) -> models_raid.RaidParticipantCheckout | None: @@ -594,3 +664,189 @@ async def get_participant_checkout_by_checkout_id( ), ) return checkout.scalars().first() + + +# --- Edition CRUDs ------------------------------------------------------ + + +async def get_all_editions( + db: AsyncSession, +) -> Sequence[models_raid.RaidEdition]: + result = await db.execute(select(models_raid.RaidEdition)) + return result.scalars().all() + + +async def get_edition_by_id( + edition_id: UUID, + db: AsyncSession, +) -> models_raid.RaidEdition | None: + result = await db.execute( + select(models_raid.RaidEdition).where( + models_raid.RaidEdition.id == edition_id, + ), + ) + return result.scalars().first() + + +async def get_active_edition( + db: AsyncSession, +) -> models_raid.RaidEdition | None: + result = await db.execute( + select(models_raid.RaidEdition).where( + models_raid.RaidEdition.active == True, # noqa: E712 + ), + ) + return result.scalars().first() + + +async def create_edition( + edition: models_raid.RaidEdition, + db: AsyncSession, +) -> models_raid.RaidEdition: + db.add(edition) + await db.flush() + return edition + + +async def update_edition( + edition_id: UUID, + edit: schemas_raid.RaidEditionEdit, + db: AsyncSession, +) -> None: + values = edit.model_dump(exclude_none=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: models_raid.RaidVolunteer, + db: AsyncSession, +) -> models_raid.RaidVolunteer: + db.add(volunteer) + await db.flush() + return volunteer + + +async def get_volunteer_by_user_id( + user_id: str, + edition_id: UUID, + db: AsyncSession, +) -> models_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, + ), + ) + return result.scalars().first() + + +async def get_all_volunteers_by_edition( + edition_id: UUID, + db: AsyncSession, + validated: bool | None = None, +) -> Sequence[models_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 result.scalars().all() + + +async def update_volunteer( + user_id: str, + edition_id: UUID, + values: dict, + db: AsyncSession, +) -> None: + if not values: + 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), + ) + 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() From 6022f4c986f3a33fdf2e4783c7d3faeaa94fe520 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:04:01 +0200 Subject: [PATCH 08/26] feat(raid): add edition-aware FastAPI dependencies for participant/volunteer lookup Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/dependencies_raid.py | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/modules/raid/dependencies_raid.py diff --git a/app/modules/raid/dependencies_raid.py b/app/modules/raid/dependencies_raid.py new file mode 100644 index 0000000000..ea6ac0da60 --- /dev/null +++ b/app/modules/raid/dependencies_raid.py @@ -0,0 +1,71 @@ +"""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, models_raid, schemas_raid + + +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 schemas_raid.RaidEdition.model_validate(edition) + + +async def get_participant_or_404( + user_id: str, + edition_id: UUID, + db: AsyncSession, +) -> models_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, +) -> models_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: + participant = await cruds_raid.get_participant_by_user_id(user_id, edition_id, db) + if participant is not None: + 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: + volunteer = await cruds_raid.get_volunteer_by_user_id(user_id, edition_id, db) + if volunteer is not None: + raise HTTPException( + status_code=400, + detail="User is already a volunteer in this edition", + ) From 290fc23f89e65a0b23dae660aed8b579ea4ee5be Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:04:11 +0200 Subject: [PATCH 09/26] feat(raid): add validation checker for participant document and field completeness Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/utils/validation_checker.py | 296 +++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 app/modules/raid/utils/validation_checker.py diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py new file mode 100644 index 0000000000..a4dde5f6ee --- /dev/null +++ b/app/modules/raid/utils/validation_checker.py @@ -0,0 +1,296 @@ +"""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 fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.raid import cruds_raid, models_raid +from app.modules.raid.raid_type import ( + DocumentValidation, + RaidRegistrationStatus, + Situation, + Size, +) + + +async def check_participant_validation_consistency( + participant: models_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: models_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: models_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: models_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: models_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: models_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: models_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: models_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_id 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: models_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 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", + ) + + +def compute_participant_progress( + participant: models_raid.RaidParticipant, +) -> float: + """Pure port of the former RaidParticipant.validation_progress @property. + + Kept as a read-only helper so the frontend can display a percentage while + RaidRegistrationStatus remains the actual source of truth. + """ + number_total = 10 + conditions = [ + participant.address, + participant.bike_size, + participant.t_shirt_size, + participant.situation, + participant.attestation_on_honour, + ] + number_validated: float = sum(condition is not None for condition in conditions) + if participant.situation in (Situation.centrale, Situation.otherSchool): + number_total += 1 + if ( + participant.student_card + and participant.student_card.validation == DocumentValidation.accepted + ): + number_validated += 1 + if participant.is_minor: + number_total += 1 + if participant.parent_authorization: + if participant.parent_authorization.validation == DocumentValidation.accepted: + number_validated += 1 + elif ( + participant.parent_authorization.validation + == DocumentValidation.temporary + ): + number_validated += 0.5 + if ( + participant.id_card + and participant.id_card.validation == DocumentValidation.accepted + ): + number_validated += 1 + if participant.medical_certificate: + if participant.medical_certificate.validation == DocumentValidation.accepted: + number_validated += 1 + elif ( + participant.medical_certificate.validation == DocumentValidation.temporary + ): + number_validated += 0.5 + if participant.security_file: + security_validation = participant.security_file.validation + if security_validation == DocumentValidation.accepted: + number_validated += 1 + elif security_validation == DocumentValidation.temporary: + number_validated += 0.5 + if ( + participant.raid_rules + and participant.raid_rules.validation == DocumentValidation.accepted + ): + number_validated += 1 + return (number_validated / number_total) * 100 + + +def compute_team_progress(team: models_raid.RaidTeam) -> float: + """Pure port of the former RaidTeam.validation_progress @property.""" + number_validated = 0 + number_total = 2 + if team.difficulty: + number_validated += 1 + if team.meeting_place: + number_validated += 1 + return (number_validated / number_total) * 10 + ( + compute_participant_progress(team.captain) + + (compute_participant_progress(team.second) if team.second else 0) + ) * 0.45 + + +def count_total_required_documents(participant: models_raid.RaidParticipant) -> int: + number_total = 3 + if participant.situation in (Situation.centrale, Situation.otherSchool): + number_total += 1 + if participant.is_minor: + number_total += 1 + return number_total + + +def count_accepted_documents(participant: models_raid.RaidParticipant) -> int: + number_validated = 0 + if ( + participant.situation in (Situation.centrale, Situation.otherSchool) + and participant.student_card + and participant.student_card.validation == DocumentValidation.accepted + ): + number_validated += 1 + if ( + participant.id_card + and participant.id_card.validation == DocumentValidation.accepted + ): + number_validated += 1 + if ( + participant.medical_certificate + and participant.medical_certificate.validation == DocumentValidation.accepted + ): + number_validated += 1 + if ( + participant.raid_rules + and participant.raid_rules.validation == DocumentValidation.accepted + ): + number_validated += 1 + if ( + participant.is_minor + and participant.parent_authorization + and participant.parent_authorization.validation == DocumentValidation.accepted + ): + number_validated += 1 + return number_validated + + +__all__ = [ + "RaidRegistrationStatus", + "check_participant_validation_consistency", + "check_volunteer_validation_consistency", + "compute_participant_progress", + "compute_team_progress", + "count_accepted_documents", + "count_total_required_documents", +] From 38ed3c60e90983a153f9bad64d3933261fcf1d99 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:04:17 +0200 Subject: [PATCH 10/26] feat(raid): add factory for seeding raid test data with editions Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/factory_raid.py | 150 +++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 app/modules/raid/factory_raid.py diff --git a/app/modules/raid/factory_raid.py b/app/modules/raid/factory_raid.py new file mode 100644 index 0000000000..b11f673303 --- /dev/null +++ b/app/modules/raid/factory_raid.py @@ -0,0 +1,150 @@ +"""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, models_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 = models_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 = models_raid.RaidParticipant( + 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, + other_school=None, + company=None, + diet=None, + id_card_id=None, + medical_certificate_id=None, + security_file_id=None, + student_card_id=None, + raid_rules_id=None, + parent_authorization_id=None, + attestation_on_honour=True, + payment=False, + t_shirt_payment=False, + is_minor=False, + ) + await cruds_raid.create_participant(participant, db) + + team = models_raid.RaidTeam( + id=cls.team_id, + edition_id=cls.edition_id, + name="Team Seed", + difficulty=Difficulty.sports, + captain_id=captain_id, + second_id=second_id, + number=None, + meeting_place=MeetingPlace.centrale, + file_id=None, + ) + await cruds_raid.create_team(team, db) + + volunteer = models_raid.RaidVolunteer( + user_id=volunteer_id, + edition_id=cls.edition_id, + created_at=datetime.now(UTC), + validated=False, + cancelled=False, + diet=None, + allergy=None, + has_car=True, + car_seats=4, + is_special_driver=False, + is_utility_vehicle_driver=False, + is_parcours_helper=True, + ) + await cruds_raid.create_volunteer(volunteer, db) From 66257e7d9a29c95609bf38cf6deaa859c43807ca Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:04:25 +0200 Subject: [PATCH 11/26] feat(raid): update payment validation and security file helpers for edition scoping Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/utils/utils_raid.py | 143 ++++++++++++++------------- 1 file changed, 74 insertions(+), 69 deletions(-) diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index 8d0d89cbf7..6a330404fe 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -1,8 +1,7 @@ import logging import zipfile - -# import uuid from datetime import UTC, date, datetime +from uuid import UUID import fitz from anyio import Path @@ -11,26 +10,16 @@ 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.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.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: @@ -63,12 +50,7 @@ def will_participant_be_minor_on( ) return ( - date( - participant.birthday.year + 18, - participant.birthday.month, - participant.birthday.day, - ) - > raid_start_date + date(birthday.year + 18, birthday.month, birthday.day) > raid_start_date ) @@ -86,14 +68,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 +91,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: models_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 = { @@ -124,26 +120,37 @@ async def set_team_number(team: models_raid.RaidTeam, db: AsyncSession) -> None: } 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, + 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: models_raid.RaidParticipant) -> dict: + """Build a template context with identity fields pulled from CoreUser.""" + ctx = { + key: value + for key, value in participant.__dict__.items() + if not key.startswith("_") + } + 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, 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 +165,26 @@ 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, ): + from app.modules.raid.utils.validation_checker import compute_team_progress + 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 @@ -209,8 +218,9 @@ def scale_rect_to_fit(container, content_width, content_height): 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,12 +232,11 @@ 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 []): + for participant in [team.captain] + ( + [team.second] if team.second else [] + ): file_id = await generate_security_file_pdf( participant, information, @@ -240,7 +249,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 +258,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 +274,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,10 +291,11 @@ 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) + 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 @@ -313,8 +319,7 @@ def calculate_raid_payment( 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 From ec6040c389c3798fa565c5b3a1ad0010f7c28f88 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:04:33 +0200 Subject: [PATCH 12/26] feat(raid): rewrite endpoints with edition scoping, state machine, and volunteer flow Co-Authored-By: Claude Opus 4.6 (1M context) --- app/modules/raid/endpoints_raid.py | 970 +++++++++++++++++++---------- 1 file changed, 642 insertions(+), 328 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index f223877bb8..611677fb04 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -18,14 +18,30 @@ 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.factory_raid import RaidFactory +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.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 @@ -52,27 +68,127 @@ 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) + model_edition = models_raid.RaidEdition( + id=uuid.uuid4(), + 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, + ) + return await cruds_raid.create_edition(model_edition, 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]), + ), +): + participants = await cruds_raid.get_all_participants(edition_id, db) + if participants: + raise HTTPException( + status_code=400, + detail="Edition has participants; 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 +197,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 +206,219 @@ 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, + 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(db_participant, 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) + values = participant_update.model_dump(exclude_none=True) + await cruds_raid.update_participant(user_id, edition.id, values, 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,20 +432,16 @@ 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()), + edition_id=edition.id, name=team.name, number=None, captain_id=user.id, @@ -257,34 +449,29 @@ async def create_team( 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) @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 +487,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 +504,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 +521,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 +543,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 +559,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 +585,47 @@ 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, id=document_id, + edition_id=edition.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) + 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, + ) return schemas_raid.DocumentCreation(id=document_id) @@ -462,19 +640,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 +660,30 @@ 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 +701,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 +721,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, ) + existing = await cruds_raid.get_security_file_by_security_id( + participant.security_file_id, + db, + ) + return existing model_security_file = models_raid.SecurityFile( id=str(uuid.uuid4()), + edition_id=edition.id, allergy=security_file.allergy, asthma=security_file.asthma, intensive_care_unit=security_file.intensive_care_unit, @@ -580,70 +771,73 @@ 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) + created = await cruds_raid.add_security_file(model_security_file, db) + await cruds_raid.assign_security_file(participant_id, edition.id, created.id, db) + return created + - 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,29 +851,24 @@ 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( 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) @@ -693,33 +882,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 +912,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 +953,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 +969,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 +977,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 +999,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,24 +1012,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) + await cruds_raid.update_participant_minority( + participant.user_id, + edition.id, + is_minor, + db, + ) @module.router.patch( @@ -876,10 +1046,6 @@ async def update_drive_folders( 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, @@ -899,9 +1065,6 @@ async def get_drive_folders( is_user_allowed_to([RaidPermissions.manage_raid]), ), ): - """ - Get drive folders - """ return await get_core_data(coredata_raid.RaidDriveFolders, db) @@ -916,9 +1079,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 +1093,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 +1112,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 +1122,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, @@ -980,15 +1140,18 @@ async def get_payment_url( await cruds_raid.create_participant_checkout( models_raid.RaidParticipantCheckout( id=str(uuid.uuid4()), - participant_id=user.id, - # TODO: use UUID + 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 +1164,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 +1185,169 @@ 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) + + model_volunteer = models_raid.RaidVolunteer( + user_id=user.id, + edition_id=edition.id, + created_at=datetime.now(UTC), + validated=False, + cancelled=False, + diet=volunteer.diet, + allergy=volunteer.allergy, + 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(model_volunteer, 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/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", + ) + values = volunteer_edit.model_dump(exclude_none=True) + await cruds_raid.update_volunteer(user_id, edition.id, values, 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) From 6e0323e09992eecdfbd9d80518b2a5c8e7b64ce5 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:04:42 +0200 Subject: [PATCH 13/26] fix(raid): update security file template to use user relationship fields Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/templates/raid_security_file.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 %} From 812c81c23b621938e90385c7c7e52ab386c178e8 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 24 Apr 2026 17:05:07 +0200 Subject: [PATCH 14/26] test(raid): rewrite tests for edition-scoped registration and volunteer flow Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/modules/raid/__init__.py | 0 tests/modules/raid/test_schemas_raid.py | 218 +++ tests/modules/raid/test_utils_raid.py | 268 +++ tests/modules/raid/test_validation_checker.py | 480 +++++ tests/modules/test_raid.py | 1582 ++++++++--------- 5 files changed, 1723 insertions(+), 825 deletions(-) create mode 100644 tests/modules/raid/__init__.py create mode 100644 tests/modules/raid/test_schemas_raid.py create mode 100644 tests/modules/raid/test_utils_raid.py create mode 100644 tests/modules/raid/test_validation_checker.py 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_schemas_raid.py b/tests/modules/raid/test_schemas_raid.py new file mode 100644 index 0000000000..4a8e5ef9d6 --- /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.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=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=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): + from app.core.groups.groups_type import AccountType + from app.core.users.schemas_users import CoreUser + + 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_utils_raid.py b/tests/modules/raid/test_utils_raid.py new file mode 100644 index 0000000000..b8187c7b89 --- /dev/null +++ b/tests/modules/raid/test_utils_raid.py @@ -0,0 +1,268 @@ +"""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_validation_checker.py b/tests/modules/raid/test_validation_checker.py new file mode 100644 index 0000000000..6354c2ccb7 --- /dev/null +++ b/tests/modules/raid/test_validation_checker.py @@ -0,0 +1,480 @@ +"""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. +""" + +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.modules.raid import 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: + from unittest.mock import AsyncMock + + edition_id = uuid4() + p = _make_validated_participant(edition_id=edition_id) + team = Mock( + spec=models_raid.RaidTeam, + second_id="other-id", + difficulty=Difficulty.sports, + meeting_place=MeetingPlace.centrale, + ) + + import app.modules.raid.cruds_raid as cruds_raid + + 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: + from unittest.mock import AsyncMock + + edition_id = uuid4() + p = _make_validated_participant(edition_id=edition_id) + team_no_second = Mock( + spec=models_raid.RaidTeam, + second_id=None, + difficulty=Difficulty.sports, + meeting_place=MeetingPlace.centrale, + ) + + import app.modules.raid.cruds_raid as cruds_raid + + 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: + from unittest.mock import AsyncMock + + edition_id = uuid4() + p = _make_validated_participant(edition_id=edition_id) + + import app.modules.raid.cruds_raid as cruds_raid + + 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: + from unittest.mock import AsyncMock + + 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: + from unittest.mock import AsyncMock + + 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: + from unittest.mock import AsyncMock + + 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: + from unittest.mock import AsyncMock + + eid = uuid4() + v = Mock( + spec=models_raid.RaidVolunteer, + edition_id=eid, + user=Mock(phone="06"), + emergency_person_name="Jane", + emergency_person_phone="06", + ) + 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..26cd992567 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -1,1039 +1,971 @@ +"""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 sqlalchemy.ext.asyncio import AsyncEngine 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 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 app.core.groups.groups_type import AccountType from tests.commons import ( + add_account_type_permission, 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 +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() + -validated_team_captain: models_users.CoreUser -validated_team_second: models_users.CoreUser +async def _ensure_tables_created() -> None: + """Work around the test harness's flaky `use_lock_for_workers` path. -token_raid_admin: str -token_simple: str -token_simple_without_participant: str -token_simple_without_team: str + In test mode the init_db startup hook may be skipped when the current + pytest process isn't selected as the "chosen worker" by psutil. Force + table creation so init_objects never races with it. + """ + from app.types.sqlalchemy import Base -token_validated_team_captain: str + session_local = get_TestingSessionLocal() + async with session_local() as db: + engine = db.bind + assert isinstance(engine, AsyncEngine) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) @pytest_asyncio.fixture(scope="module", autouse=True) -async def init_objects() -> None: - global admin_group +async def init_objects(client) -> None: + await _ensure_tables_created() + + # The init_db startup hook normally seeds each module's access permission + # against the default account types. When the fallback worker selection + # skips init_db we also need to seed them; when it runs we must not + # double-insert. Wrap in try/except to stay idempotent. + for account_type in AccountType: + try: + await add_account_type_permission( + RaidPermissions.access_raid, + account_type, + ) + except Exception: # noqa: BLE001, S110 + pass + + 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.date.today(), + type=DocumentType.idCard, + validation=DocumentValidation.accepted, ) - token_simple_without_team = create_api_access_token(simple_user_without_team) + await add_object_to_db(doc_accepted) - 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([]) - - 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.date.today(), + 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, - ) - 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", + 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(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(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_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, +# --------------------------------------------------------------------------- +# Edition endpoints +# --------------------------------------------------------------------------- + + +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) - 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()), +def test_list_editions_requires_admin(client: TestClient) -> None: + r = client.get( + "/raid/editions", + headers={"Authorization": f"Bearer {token_captain}"}, ) + assert r.status_code == 403 - 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, - ) - await add_coredata_to_db( - coredata_raid.RaidPrice( - student_price=50, - t_shirt_price=15, - external_price=90, - ), +def test_list_editions_as_admin(client: TestClient) -> None: + r = client.get( + "/raid/editions", + headers={"Authorization": f"Bearer {token_admin}"}, ) + assert r.status_code == 200 + assert any(e["id"] == str(active_edition.id) for e in r.json()) -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_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 response.status_code == 200 - assert response.json()["id"] == simple_user.id - - -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}"}, + 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 == 201 - assert response.json()["firstname"] == "New" + assert d.status_code == 204 -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_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 -# 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}"}, - ) - assert response.status_code == 204 - - -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}"}, - ) - assert response.status_code == 204 +# --------------------------------------------------------------------------- +# Participants: state machine +# --------------------------------------------------------------------------- -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_self(client: TestClient) -> None: + r = client.get( + f"/raid/participants/{user_captain.id}", + headers={"Authorization": f"Bearer {token_captain}"}, ) - assert response.status_code == 403 - assert response.json()["detail"] == "You are not the participant." + 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_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_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_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_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 == 204 + assert r.status_code == 200 -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_create_participant_missing_identity_400(client: TestClient) -> None: + r = client.post( + "/raid/participants", + headers={"Authorization": f"Bearer {token_no_raid}"}, ) - assert response.status_code == 204 + assert r.status_code == 400 + assert "birthday or phone" in r.json()["detail"] -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_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"] == "Document id_card 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_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_twice_rejected(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 == 403 -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_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 == 201 - assert response.json()["name"] == "New Team" + assert r.status_code == 204 -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_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 "id" in response.json() + assert r.status_code == 403 -def test_get_all_teams(client: TestClient): - response = client.get( - "/raid/teams", - 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 isinstance(response.json(), list) + assert r.status_code == 204 -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_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 == 200 - assert response.json()["id"] == team.id + assert r.status_code == 404 -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_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_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_documents_400(client: TestClient) -> None: + client.post( + f"/raid/participant/{user_captain.id}/honour", + headers={"Authorization": f"Bearer {token_captain}"}, ) - assert response.status_code == 204 + r = client.post( + f"/raid/participants/{user_captain.id}/submit", + headers={"Authorization": f"Bearer {token_captain}"}, + ) + assert r.status_code == 400 -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_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 == 201 - assert "id" in response.json() + 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}"}, - ) - assert response.status_code == 404 - assert response.json()["detail"] == "Document not found." +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.date.today(), + type=doc_type, + validation=DocumentValidation.accepted, + ) + db.add(doc) + docs[doc_type] = doc + await db.flush() + 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() -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}"}, + 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 upload_response.status_code == 201 - document_id = upload_response.json()["id"] + assert r.status_code == 204, r.json() + + r = client.get( + f"/raid/participants/{user_captain.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert r.json()["status"] == "validated" - # 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}"}, +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 == 404 - assert response.json()["detail"] == "Participant owning the document not found." + assert r.status_code == 400 -# 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}"}, +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 response.status_code == 204 + assert r.status_code == 204 -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}"}, +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 response.status_code == 201 - assert "id" in response.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}"}, + assert r.status_code == 403 + + +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 == 403 - assert response.json()["detail"] == "You are not the participant." - - -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}"}, + assert r.status_code == 204 + r2 = 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." - - -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}"}, + assert r2.json()["status"] == "draft" + + +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 initial_response.status_code == 201 - - # 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}"}, + assert r.status_code == 204 + r2 = client.get( + f"/raid/participants/{user_solo.id}", + headers={"Authorization": f"Bearer {token_solo}"}, ) - assert update_response.status_code == 201 - assert update_response.json()["id"] != initial_response.json()["id"] + assert r2.json()["status"] == "cancelled" -def test_validate_attestation_on_honour(client: TestClient): - response = client.post( - f"/raid/participant/{simple_user.id}/honour", - headers={"Authorization": f"Bearer {token_simple}"}, - ) - assert response.status_code == 204 +# --------------------------------------------------------------------------- +# Teams +# --------------------------------------------------------------------------- -# 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_list_teams_requires_admin(client: TestClient) -> None: + r = client.get( + "/raid/teams", + headers={"Authorization": f"Bearer {token_captain}"}, ) - assert create_token_response.status_code == 201 - token = create_token_response.json()["token"] + assert r.status_code == 403 - # 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}"}, + +def test_list_teams_as_admin(client: TestClient) -> None: + r = client.get( + "/raid/teams", + headers={"Authorization": f"Bearer {token_admin}"}, ) - assert response.status_code == 204 + assert r.status_code == 200 + assert isinstance(r.json(), list) + assert len(r.json()) >= 2 -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}"}, +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 == 201 + assert r.status_code == 200 + assert r.json()["captain"]["user_id"] == user_captain.id -# 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_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 == 201 - assert "token" in response.json() + assert r.status_code == 204 -# Fail due to pdf writing error -def test_merge_teams(client: TestClient): - # Create two teams for testing - team1_id = team.id +# --------------------------------------------------------------------------- +# Documents +# --------------------------------------------------------------------------- - team2_response = client.post( - "/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}"}, + +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 == 201 + assert r.status_code == 201 -def test_get_raid_information(client: TestClient): - response = client.get( - "/raid/information", - headers={"Authorization": f"Bearer {token_simple}"}, +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 == 200 + assert r.status_code == 403 -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_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 -def test_get_raid_price(client: TestClient): - response = client.get( - "/raid/price", - headers={"Authorization": f"Bearer {token_simple}"}, - ) - assert response.status_code == 200 +# --------------------------------------------------------------------------- +# Payment +# --------------------------------------------------------------------------- -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_payment_url_requires_participant(client: TestClient) -> None: + r = client.get( + "/raid/pay", + headers={"Authorization": f"Bearer {token_no_raid}"}, ) - 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_confirm_payment_requires_admin(client: TestClient) -> None: + r = client.post( + f"/raid/participant/{user_second.id}/payment", + headers={"Authorization": f"Bearer {token_captain}"}, ) - assert response.status_code == 204 + assert r.status_code == 403 -## Test for pdf writer +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 -@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_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 -@pytest.fixture -def mock_security_file(): - return Mock(spec=SecurityFile, allergy="None", asthma=False) +# --------------------------------------------------------------------------- +# Volunteers +# --------------------------------------------------------------------------- -@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_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 -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_create_volunteer(client: TestClient) -> None: + r = client.post( + "/raid/volunteers", + json={ + "diet": "veggie", + "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 + - # 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, +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 update_team function - mock_update_team = mocker.patch("app.modules.raid.cruds_raid.update_team") - # Call the function +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 - 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 == 101 # 100 (sports separator) + 1 +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 -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_list_volunteers_admin_only(client: TestClient) -> None: + r = client.get( + "/raid/volunteers", + 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_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 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_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_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, + 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 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 not called - mock_update_team.assert_not_called() + r = client.patch( + f"/raid/volunteers/{user_volunteer.id}/validate", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert r.status_code == 204 -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, +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}"}, ) + assert r.status_code == 403 - # 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 - - 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 == 6 # discovery (0) + 5 + 1 - - -@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, - ) - 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"], + +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}"}, ) - price, _ = calculate_raid_payment(participant, raid_prices) - assert price == expected_price + assert r.status_code == 204 -def test_download_security_files_zip(client: TestClient): - response = client.get( - "/raid/security_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_download_team_files_zip(client: TestClient): - response = client.get( - "/raid/team_files_zip", - headers={"Authorization": f"Bearer {token_raid_admin}"}, - ) - assert response.status_code == 200 +# --------------------------------------------------------------------------- +# Raw CRUD integration tests (edition-aware) +# --------------------------------------------------------------------------- -def test_delete_all_teams(client: TestClient): - response = client.delete( - "/raid/teams", - headers={"Authorization": f"Bearer {token_raid_admin}"}, - ) - assert response.status_code == 204 +@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 - 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_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_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_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, + ) -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_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 = models_raid.RaidVolunteer( + 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 = models_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() From 5ad6ea1b5e2277cebe21f99eef509036224212fb Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Sun, 17 May 2026 16:34:48 +0200 Subject: [PATCH 15/26] fix: test and linting --- app/modules/raid/cruds_raid.py | 8 ++-- app/modules/raid/endpoints_raid.py | 8 ++-- app/modules/raid/models_raid.py | 3 ++ app/modules/raid/schemas_raid.py | 7 ++- app/modules/raid/utils/validation_checker.py | 5 +++ .../versions/59-raid_editions_and_state.py | 45 ++++++++++++------- migrations/versions/60-raid_volunteers.py | 14 ++++++ tests/modules/raid/test_schemas_raid.py | 1 - tests/modules/raid/test_utils_raid.py | 1 - tests/modules/raid/test_validation_checker.py | 10 +++-- tests/modules/test_raid.py | 12 ++--- 11 files changed, 80 insertions(+), 34 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index 2013cc953f..d11057c800 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -142,8 +142,8 @@ async def get_all_validated_teams( db: AsyncSession, ) -> Sequence[models_raid.RaidTeam]: """Validated = captain AND second both have status=validated.""" - Captain = models_raid.RaidParticipant.__table__.alias("captain_p") # noqa: N806 - Second = models_raid.RaidParticipant.__table__.alias("second_p") # noqa: N806 + 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) @@ -618,8 +618,8 @@ async def get_max_team_number_by_difficulty( Validated = both captain and second have status=validated. """ - Captain = models_raid.RaidParticipant.__table__.alias("captain_p") # noqa: N806 - Second = models_raid.RaidParticipant.__table__.alias("second_p") # noqa: N806 + 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( diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 611677fb04..68f9ef8e80 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -18,7 +18,6 @@ is_user_allowed_to, ) from app.modules.raid import coredata_raid, cruds_raid, models_raid, schemas_raid -from app.modules.raid.factory_raid import RaidFactory from app.modules.raid.dependencies_raid import ( ensure_user_is_not_participant_in_edition, ensure_user_is_not_volunteer_in_edition, @@ -26,6 +25,7 @@ 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, @@ -747,11 +747,10 @@ async def set_security_file( security_file=security_file, db=db, ) - existing = await cruds_raid.get_security_file_by_security_id( + return await cruds_raid.get_security_file_by_security_id( participant.security_file_id, db, ) - return existing model_security_file = models_raid.SecurityFile( id=str(uuid.uuid4()), @@ -1224,8 +1223,11 @@ async def create_volunteer( 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, diff --git a/app/modules/raid/models_raid.py b/app/modules/raid/models_raid.py index e0d825e005..8cabd3187f 100644 --- a/app/modules/raid/models_raid.py +++ b/app/modules/raid/models_raid.py @@ -261,6 +261,9 @@ class RaidVolunteer(Base): 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) diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index a5e99241a3..96bc76be54 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -189,7 +189,6 @@ class RaidTeamPreview(RaidTeamBase): @computed_field # type: ignore[prop-decorator] @property def validation_progress(self) -> float: - from app.modules.raid.utils.validation_checker import compute_team_progress captain_progress = ( self.captain.validation_progress @@ -285,8 +284,11 @@ class RaidEditionEdit(BaseModel): class RaidVolunteerBase(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 = False car_seats: int | None = None is_special_driver: bool = False @@ -315,8 +317,11 @@ class RaidVolunteer(RaidVolunteerBase): 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 diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py index a4dde5f6ee..fc78724990 100644 --- a/app/modules/raid/utils/validation_checker.py +++ b/app/modules/raid/utils/validation_checker.py @@ -161,6 +161,11 @@ async def check_volunteer_validation_consistency( 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 ): diff --git a/migrations/versions/59-raid_editions_and_state.py b/migrations/versions/59-raid_editions_and_state.py index 6b46b5f92a..b00154ced2 100644 --- a/migrations/versions/59-raid_editions_and_state.py +++ b/migrations/versions/59-raid_editions_and_state.py @@ -3,6 +3,7 @@ Create Date: 2026-04-21 00:00:00.000000 """ +import contextlib import uuid from collections.abc import Sequence from enum import Enum @@ -30,8 +31,8 @@ class RaidRegistrationStatus(Enum): class SituationEnum(Enum): centrale = "centrale" - otherSchool = "otherSchool" # noqa: N815 - corporatePartner = "corporatePartner" # noqa: N815 + otherSchool = "otherSchool" + corporatePartner = "corporatePartner" other = "other" @@ -111,12 +112,18 @@ def upgrade() -> None: ("raid_team_second_id_fkey", "raid_team"), ("raid_participant_checkout_participant_id_fkey", "raid_participant_checkout"), ): - try: + with contextlib.suppress(Exception): op.drop_constraint(fk_name, table, type_="foreignkey") - except Exception: # noqa: BLE001 - legacy FK names vary - pass 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), @@ -223,10 +230,8 @@ def upgrade() -> None: op.drop_column("raid_participant", "phone") # Promote PK to composite + add FKs. - try: + with contextlib.suppress(Exception): op.drop_constraint("raid_participant_pkey", "raid_participant", type_="primary") - except Exception: # noqa: BLE001 - pass op.create_primary_key( "raid_participant_pkey", "raid_participant", @@ -312,7 +317,7 @@ def upgrade() -> None: ) conn.execute( sa.text( - f"UPDATE {table} SET edition_id = :eid WHERE edition_id IS NULL", # noqa: S608 + 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) @@ -343,13 +348,8 @@ def downgrade() -> None: "participant_user_id", new_column_name="participant_id", ) - op.create_foreign_key( - "raid_participant_checkout_participant_id_fkey", - "raid_participant_checkout", - "raid_participant", - ["participant_id"], - ["user_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") @@ -417,7 +417,13 @@ def downgrade() -> None: 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", @@ -432,6 +438,13 @@ def downgrade() -> None: ["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) diff --git a/migrations/versions/60-raid_volunteers.py b/migrations/versions/60-raid_volunteers.py index 2062fa166b..d7071f602e 100644 --- a/migrations/versions/60-raid_volunteers.py +++ b/migrations/versions/60-raid_volunteers.py @@ -11,6 +11,7 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy.dialects import postgresql from app.types.sqlalchemy import TZDateTime @@ -22,6 +23,16 @@ 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), @@ -29,8 +40,11 @@ def upgrade() -> None: 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(), diff --git a/tests/modules/raid/test_schemas_raid.py b/tests/modules/raid/test_schemas_raid.py index 4a8e5ef9d6..578379a251 100644 --- a/tests/modules/raid/test_schemas_raid.py +++ b/tests/modules/raid/test_schemas_raid.py @@ -22,7 +22,6 @@ Size, ) - # -- RaidParticipantUpdate: situation validators --------------------------- diff --git a/tests/modules/raid/test_utils_raid.py b/tests/modules/raid/test_utils_raid.py index b8187c7b89..e4d0ee07e4 100644 --- a/tests/modules/raid/test_utils_raid.py +++ b/tests/modules/raid/test_utils_raid.py @@ -27,7 +27,6 @@ will_birthday_be_minor_on, ) - # -- will_birthday_be_minor_on --------------------------------------------- diff --git a/tests/modules/raid/test_validation_checker.py b/tests/modules/raid/test_validation_checker.py index 6354c2ccb7..b0ce92a18b 100644 --- a/tests/modules/raid/test_validation_checker.py +++ b/tests/modules/raid/test_validation_checker.py @@ -7,6 +7,8 @@ assert the exact strings so they stay stable. """ +# ruff: noqa: SLF001 # tests deliberately exercise private sub-checks + from unittest.mock import Mock from uuid import uuid4 @@ -255,7 +257,7 @@ async def test_full_participant_checker_passes_for_valid_data() -> None: meeting_place=MeetingPlace.centrale, ) - import app.modules.raid.cruds_raid as cruds_raid + from app.modules.raid import cruds_raid original = cruds_raid.get_team_by_participant_id cruds_raid.get_team_by_participant_id = AsyncMock(return_value=team) @@ -282,7 +284,7 @@ async def test_full_participant_checker_fails_when_team_incomplete() -> None: meeting_place=MeetingPlace.centrale, ) - import app.modules.raid.cruds_raid as cruds_raid + from app.modules.raid import cruds_raid original = cruds_raid.get_team_by_participant_id cruds_raid.get_team_by_participant_id = AsyncMock(return_value=team_no_second) @@ -305,7 +307,7 @@ async def test_full_participant_checker_fails_when_no_team() -> None: edition_id = uuid4() p = _make_validated_participant(edition_id=edition_id) - import app.modules.raid.cruds_raid as cruds_raid + from app.modules.raid import cruds_raid original = cruds_raid.get_team_by_participant_id cruds_raid.get_team_by_participant_id = AsyncMock(return_value=None) @@ -397,6 +399,8 @@ async def test_check_volunteer_passes_for_complete_profile() -> None: 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(), diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index 26cd992567..cdd00568b6 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine from app.core.groups import models_groups +from app.core.groups.groups_type import AccountType from app.core.users import cruds_users, models_users, schemas_users from app.modules.raid import coredata_raid, cruds_raid, models_raid from app.modules.raid.endpoints_raid import RaidPermissions @@ -30,7 +31,6 @@ Situation, Size, ) -from app.core.groups.groups_type import AccountType from tests.commons import ( add_account_type_permission, add_coredata_to_db, @@ -110,7 +110,7 @@ async def init_objects(client) -> None: RaidPermissions.access_raid, account_type, ) - except Exception: # noqa: BLE001, S110 + except Exception: # noqa: S110 pass global admin_group, active_edition @@ -182,7 +182,7 @@ async def init_objects(client) -> None: id=str(uuid.uuid4()), edition_id=active_edition.id, name="accepted.pdf", - uploaded_at=datetime.date.today(), + uploaded_at=datetime.datetime.now(tz=datetime.UTC).date(), type=DocumentType.idCard, validation=DocumentValidation.accepted, ) @@ -192,7 +192,7 @@ async def init_objects(client) -> None: id=str(uuid.uuid4()), edition_id=active_edition.id, name="pending.pdf", - uploaded_at=datetime.date.today(), + uploaded_at=datetime.datetime.now(tz=datetime.UTC).date(), type=DocumentType.medicalCertificate, validation=DocumentValidation.pending, ) @@ -457,7 +457,7 @@ async def _prepare_full_validation_state() -> None: id=str(uuid.uuid4()), edition_id=active_edition.id, name=f"{doc_type.value}.pdf", - uploaded_at=datetime.date.today(), + uploaded_at=datetime.datetime.now(tz=datetime.UTC).date(), type=doc_type, validation=DocumentValidation.accepted, ) @@ -708,6 +708,8 @@ def test_create_volunteer(client: TestClient) -> None: "/raid/volunteers", json={ "diet": "veggie", + "emergency_person_name": "Jane Doe", + "emergency_person_phone": "+33611111111", "has_car": True, "car_seats": 4, "is_parcours_helper": True, From 77da0f3e1577aaf13e08a14bc9b4269b905ce165 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 5 Jun 2026 23:05:02 +0200 Subject: [PATCH 16/26] refacto(cruds): splitting models from schema --- app/core/schools/schemas_schools.py | 4 +- app/modules/raid/cruds_raid.py | 279 +++++++++++++----- app/modules/raid/dependencies_raid.py | 8 +- app/modules/raid/endpoints_raid.py | 61 ++-- app/modules/raid/factory_raid.py | 30 +- app/modules/raid/schemas_raid.py | 95 +++++- app/modules/raid/utils/utils_raid.py | 20 +- app/modules/raid/utils/validation_checker.py | 30 +- tests/modules/raid/test_schemas_raid.py | 2 + tests/modules/raid/test_validation_checker.py | 4 +- tests/modules/test_raid.py | 6 +- 11 files changed, 362 insertions(+), 177 deletions(-) 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/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index d11057c800..a4bc360b02 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -1,4 +1,4 @@ -from collections.abc import Sequence +import uuid from datetime import UTC, datetime from uuid import UUID @@ -15,19 +15,41 @@ async def create_participant( - participant: models_raid.RaidParticipant, - db: AsyncSession, -) -> models_raid.RaidParticipant: - db.add(participant) + participant: schemas_raid.RaidParticipantCreate, + db: AsyncSession, +) -> 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, status: RaidRegistrationStatus | None = None, -) -> Sequence[models_raid.RaidParticipant]: +) -> list[schemas_raid.RaidParticipant]: stmt = ( select(models_raid.RaidParticipant) .where(models_raid.RaidParticipant.edition_id == edition_id) @@ -36,7 +58,10 @@ async def get_all_participants( if status is not None: stmt = stmt.where(models_raid.RaidParticipant.status == status) participants = await db.execute(stmt) - return participants.scalars().all() + return [ + schemas_raid.RaidParticipant.model_validate(p) + for p in participants.scalars().all() + ] async def update_participant( @@ -110,7 +135,7 @@ async def get_team_by_participant_id( 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( @@ -122,25 +147,26 @@ async def get_team_by_participant_id( ) .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) .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( edition_id: UUID, db: AsyncSession, -) -> Sequence[models_raid.RaidTeam]: +) -> list[schemas_raid.RaidTeam]: """Validated = captain AND second both have status=validated.""" Captain = models_raid.RaidParticipant.__table__.alias("captain_p") Second = models_raid.RaidParticipant.__table__.alias("second_p") @@ -164,26 +190,39 @@ async def get_all_validated_teams( .options(selectinload("*")) ) teams = await db.execute(stmt) - return teams.scalars().all() + 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(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, - db: AsyncSession, -) -> None: - db.add(team) + team: schemas_raid.RaidTeamCreate, + db: AsyncSession, +) -> None: + 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() @@ -302,12 +341,31 @@ async def delete_all_teams( 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( @@ -366,12 +424,21 @@ async def assign_security_file( 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( @@ -408,19 +475,21 @@ 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, @@ -428,18 +497,11 @@ async def get_user_by_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( @@ -458,7 +520,7 @@ 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) @@ -519,7 +581,7 @@ 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( @@ -528,7 +590,8 @@ async def get_participant_by_user_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( @@ -546,44 +609,53 @@ async def get_number_of_teams( 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( @@ -646,24 +718,33 @@ async def get_max_team_number_by_difficulty( 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( 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 ------------------------------------------------------ @@ -671,41 +752,55 @@ async def get_participant_checkout_by_checkout_id( async def get_all_editions( db: AsyncSession, -) -> Sequence[models_raid.RaidEdition]: +) -> list[schemas_raid.RaidEdition]: result = await db.execute(select(models_raid.RaidEdition)) - return result.scalars().all() + return [ + schemas_raid.RaidEdition.model_validate(e) for e in result.scalars().all() + ] async def get_edition_by_id( edition_id: UUID, db: AsyncSession, -) -> models_raid.RaidEdition | None: +) -> schemas_raid.RaidEdition | None: result = await db.execute( select(models_raid.RaidEdition).where( models_raid.RaidEdition.id == edition_id, ), ) - return result.scalars().first() + model = result.scalars().first() + return schemas_raid.RaidEdition.model_validate(model) if model else None async def get_active_edition( db: AsyncSession, -) -> models_raid.RaidEdition | None: +) -> schemas_raid.RaidEdition | None: result = await db.execute( select(models_raid.RaidEdition).where( models_raid.RaidEdition.active == True, # noqa: E712 ), ) - return result.scalars().first() + model = result.scalars().first() + return schemas_raid.RaidEdition.model_validate(model) if model else None async def create_edition( - edition: models_raid.RaidEdition, - db: AsyncSession, -) -> models_raid.RaidEdition: - db.add(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() - return edition async def update_edition( @@ -749,40 +844,60 @@ async def deactivate_all_editions( async def create_volunteer( - volunteer: models_raid.RaidVolunteer, - db: AsyncSession, -) -> models_raid.RaidVolunteer: - db.add(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() - return volunteer async def get_volunteer_by_user_id( user_id: str, edition_id: UUID, db: AsyncSession, -) -> models_raid.RaidVolunteer | None: +) -> 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, ), ) - return result.scalars().first() + 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, -) -> Sequence[models_raid.RaidVolunteer]: +) -> 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 result.scalars().all() + return [ + schemas_raid.RaidVolunteer.model_validate(v) for v in result.scalars().all() + ] async def update_volunteer( diff --git a/app/modules/raid/dependencies_raid.py b/app/modules/raid/dependencies_raid.py index ea6ac0da60..8862d25ab6 100644 --- a/app/modules/raid/dependencies_raid.py +++ b/app/modules/raid/dependencies_raid.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db -from app.modules.raid import cruds_raid, models_raid, schemas_raid +from app.modules.raid import cruds_raid, schemas_raid async def get_current_raid_edition( @@ -20,14 +20,14 @@ async def get_current_raid_edition( edition = await cruds_raid.get_active_edition(db) if not edition: raise HTTPException(status_code=404, detail="No active raid edition") - return schemas_raid.RaidEdition.model_validate(edition) + return edition async def get_participant_or_404( user_id: str, edition_id: UUID, db: AsyncSession, -) -> models_raid.RaidParticipant: +) -> 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") @@ -38,7 +38,7 @@ async def get_volunteer_or_404( user_id: str, edition_id: UUID, db: AsyncSession, -) -> models_raid.RaidVolunteer: +) -> 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") diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 68f9ef8e80..f4f1a07b8b 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -17,7 +17,7 @@ get_payment_tool, is_user_allowed_to, ) -from app.modules.raid import coredata_raid, cruds_raid, models_raid, schemas_raid +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, @@ -117,8 +117,9 @@ async def create_edition( ): if edition.active: await cruds_raid.deactivate_all_editions(db) - model_edition = models_raid.RaidEdition( - id=uuid.uuid4(), + edition_id = uuid.uuid4() + edition_schema = schemas_raid.RaidEdition( + id=edition_id, name=edition.name, year=edition.year, start_date=edition.start_date, @@ -127,7 +128,8 @@ async def create_edition( active=edition.active, inscription_enabled=edition.inscription_enabled, ) - return await cruds_raid.create_edition(model_edition, db) + await cruds_raid.create_edition(edition_schema, db) + return await cruds_raid.get_edition_by_id(edition_id, db) @module.router.patch( @@ -230,13 +232,13 @@ async def create_participant( raid_start_date=raid_information.raid_start_date, ) - db_participant = models_raid.RaidParticipant( + participant_create = schemas_raid.RaidParticipantCreate( user_id=user.id, edition_id=edition.id, status=RaidRegistrationStatus.draft, is_minor=is_minor, ) - 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) @@ -439,8 +441,9 @@ async def create_team( 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, @@ -448,8 +451,8 @@ async def create_team( second_id=None, difficulty=None, ) - await cruds_raid.create_team(db_team, db) - 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( @@ -602,15 +605,14 @@ async def upload_document( ], ) - model_document = models_raid.Document( + document_schema = schemas_raid.Document( id=document_id, - edition_id=edition.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) + await cruds_raid.create_document(document_schema, edition.id, db) document_key = { DocumentType.idCard: "id_card_id", @@ -752,9 +754,10 @@ async def set_security_file( db, ) - model_security_file = models_raid.SecurityFile( - id=str(uuid.uuid4()), - edition_id=edition.id, + 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, @@ -770,9 +773,17 @@ async def set_security_file( emergency_person_phone=security_file.emergency_person_phone, file_id=security_file.file_id, ) - created = await cruds_raid.add_security_file(model_security_file, db) - await cruds_raid.assign_security_file(participant_id, edition.id, created.id, db) - return created + 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, + ) # --------------------------------------------------------------------------- @@ -862,13 +873,14 @@ async def create_invite_token( 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( @@ -1137,8 +1149,7 @@ 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()), + schemas_raid.RaidParticipantCheckout( participant_user_id=user.id, edition_id=edition.id, checkout_id=str(checkout.id), @@ -1217,7 +1228,7 @@ async def create_volunteer( raise HTTPException(status_code=403, detail="You are already a volunteer.") await ensure_user_is_not_participant_in_edition(user.id, edition.id, db) - model_volunteer = models_raid.RaidVolunteer( + volunteer_create = schemas_raid.RaidVolunteerCreate( user_id=user.id, edition_id=edition.id, created_at=datetime.now(UTC), @@ -1234,7 +1245,7 @@ async def create_volunteer( is_utility_vehicle_driver=volunteer.is_utility_vehicle_driver, is_parcours_helper=volunteer.is_parcours_helper, ) - await cruds_raid.create_volunteer(model_volunteer, db) + await cruds_raid.create_volunteer(volunteer_create, db) return await get_volunteer_or_404(user.id, edition.id, db) diff --git a/app/modules/raid/factory_raid.py b/app/modules/raid/factory_raid.py index b11f673303..a71dda8269 100644 --- a/app/modules/raid/factory_raid.py +++ b/app/modules/raid/factory_raid.py @@ -16,7 +16,7 @@ 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, models_raid +from app.modules.raid import cruds_raid, schemas_raid from app.modules.raid.raid_type import ( Difficulty, MeetingPlace, @@ -77,7 +77,7 @@ async def _ensure_raid_admin_group(cls, db: AsyncSession) -> None: async def run(cls, db: AsyncSession, settings: Settings) -> None: await cls._ensure_raid_admin_group(db) - edition = models_raid.RaidEdition( + edition = schemas_raid.RaidEdition( id=cls.edition_id, year=datetime.now(UTC).year, name="Raid", @@ -96,7 +96,7 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: captain_id, second_id, volunteer_id = seed_users for idx, uid in enumerate((captain_id, second_id)): - participant = models_raid.RaidParticipant( + participant = schemas_raid.RaidParticipantCreate( user_id=uid, edition_id=cls.edition_id, status=RaidRegistrationStatus.submitted, @@ -104,47 +104,27 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: bike_size=Size.M, t_shirt_size=Size.M, situation=Situation.centrale, - other_school=None, - company=None, - diet=None, - id_card_id=None, - medical_certificate_id=None, - security_file_id=None, - student_card_id=None, - raid_rules_id=None, - parent_authorization_id=None, attestation_on_honour=True, - payment=False, - t_shirt_payment=False, - is_minor=False, ) await cruds_raid.create_participant(participant, db) - team = models_raid.RaidTeam( + 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, - number=None, meeting_place=MeetingPlace.centrale, - file_id=None, ) await cruds_raid.create_team(team, db) - volunteer = models_raid.RaidVolunteer( + volunteer = schemas_raid.RaidVolunteerCreate( user_id=volunteer_id, edition_id=cls.edition_id, created_at=datetime.now(UTC), - validated=False, - cancelled=False, - diet=None, - allergy=None, has_car=True, car_seats=4, - is_special_driver=False, - is_utility_vehicle_driver=False, is_parcours_helper=True, ) await cruds_raid.create_volunteer(volunteer, db) diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 96bc76be54..f4d37b45bc 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -40,6 +40,8 @@ class Document(DocumentBase): uploaded_at: date validation: DocumentValidation + model_config = ConfigDict(from_attributes=True) + class SecurityFileBase(BaseModel): allergy: str | None = None @@ -62,6 +64,8 @@ class SecurityFile(SecurityFileBase): validation: DocumentValidation id: str + model_config = ConfigDict(from_attributes=True) + class RaidParticipantBase(BaseModel): """Shape used when the user first self-enrols. @@ -72,6 +76,31 @@ class RaidParticipantBase(BaseModel): """ +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): user_id: str edition_id: UUID @@ -91,11 +120,17 @@ class RaidParticipant(RaidParticipantPreview): other_school: str | None = None company: str | None = None diet: str | None = 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 @@ -179,7 +214,9 @@ class RaidTeamPreview(RaidTeamBase): id: str edition_id: UUID number: int | None = None + captain_id: str captain: RaidParticipantPreview + second_id: str | None = None second: RaidParticipantPreview | None = None difficulty: Difficulty | None = None meeting_place: MeetingPlace | None = None @@ -208,7 +245,9 @@ class RaidTeam(RaidTeamBase): id: str edition_id: UUID number: int | None = None + captain_id: str captain: RaidParticipant + second_id: str | None = None second: RaidParticipant | None = None difficulty: Difficulty | None = None meeting_place: MeetingPlace | None = None @@ -225,6 +264,20 @@ def validation_progress(self) -> float: 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): name: str | None = None number: int | None = None @@ -233,9 +286,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 @@ -256,6 +313,8 @@ class RaidParticipantCheckout(BaseModel): edition_id: UUID checkout_id: str + model_config = ConfigDict(from_attributes=True) + class RaidEditionBase(BaseModel): name: str @@ -284,6 +343,14 @@ class RaidEditionEdit(BaseModel): 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 @@ -295,14 +362,26 @@ class RaidVolunteerBase(BaseModel): is_utility_vehicle_driver: bool = False is_parcours_helper: bool = False - @model_validator(mode="after") - def _check_car_seats_consistency(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 + +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): diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index 6a330404fe..ee2e757be6 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -9,7 +9,7 @@ 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 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 ( get_difficulty_label, @@ -102,7 +102,7 @@ async def validate_payment( async def set_team_number( - team: models_raid.RaidTeam, + team: schemas_raid.RaidTeam, edition_id: UUID, db: AsyncSession, ) -> None: @@ -127,13 +127,9 @@ async def set_team_number( await cruds_raid.update_team(team.id, updated_team, db) -def _participant_pdf_context(participant: models_raid.RaidParticipant) -> dict: +def _participant_pdf_context(participant: schemas_raid.RaidParticipant) -> dict: """Build a template context with identity fields pulled from CoreUser.""" - ctx = { - key: value - for key, value in participant.__dict__.items() - if not key.startswith("_") - } + ctx = participant.model_dump() if participant.user is not None: ctx["name"] = participant.user.name ctx["firstname"] = participant.user.firstname @@ -144,7 +140,7 @@ def _participant_pdf_context(participant: models_raid.RaidParticipant) -> dict: async def generate_security_file_pdf( - participant: models_raid.RaidParticipant, + participant: schemas_raid.RaidParticipant, information: coredata_raid.RaidInformation, team_number: int | None = None, ): @@ -173,7 +169,7 @@ async def generate_security_file_pdf( async def generate_recap_file_pdf( - team: models_raid.RaidTeam, + team: schemas_raid.RaidTeam, ): from app.modules.raid.utils.validation_checker import compute_team_progress @@ -294,7 +290,7 @@ async def get_participant( user_id: str, edition_id: UUID, db: AsyncSession, -) -> models_raid.RaidParticipant: +) -> 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.") @@ -302,7 +298,7 @@ async def get_participant( def calculate_raid_payment( - participant: models_raid.RaidParticipant, + participant: schemas_raid.RaidParticipant, raid_prices: coredata_raid.RaidPrice, ): if ( diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py index fc78724990..7492a538fc 100644 --- a/app/modules/raid/utils/validation_checker.py +++ b/app/modules/raid/utils/validation_checker.py @@ -8,7 +8,7 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.raid import cruds_raid, models_raid +from app.modules.raid import cruds_raid, schemas_raid from app.modules.raid.raid_type import ( DocumentValidation, RaidRegistrationStatus, @@ -18,7 +18,7 @@ async def check_participant_validation_consistency( - participant: models_raid.RaidParticipant, + participant: schemas_raid.RaidParticipant, edition_id, db: AsyncSession, ) -> None: @@ -33,7 +33,7 @@ async def check_participant_validation_consistency( def _check_edition_scope( - participant: models_raid.RaidParticipant, + participant: schemas_raid.RaidParticipant, edition_id, ) -> None: if participant.edition_id != edition_id: @@ -43,7 +43,7 @@ def _check_edition_scope( ) -def _check_attestation_signed(participant: models_raid.RaidParticipant) -> None: +def _check_attestation_signed(participant: schemas_raid.RaidParticipant) -> None: if not participant.attestation_on_honour: raise HTTPException( status_code=400, @@ -51,7 +51,7 @@ def _check_attestation_signed(participant: models_raid.RaidParticipant) -> None: ) -def _check_payment_done(participant: models_raid.RaidParticipant) -> None: +def _check_payment_done(participant: schemas_raid.RaidParticipant) -> None: if not participant.payment: raise HTTPException( status_code=400, @@ -68,7 +68,7 @@ def _check_payment_done(participant: models_raid.RaidParticipant) -> None: ) -def _check_security_file_complete(participant: models_raid.RaidParticipant) -> None: +def _check_security_file_complete(participant: schemas_raid.RaidParticipant) -> None: security_file = participant.security_file if security_file is None: raise HTTPException( @@ -86,7 +86,7 @@ def _check_security_file_complete(participant: models_raid.RaidParticipant) -> N ) -def _check_all_documents_accepted(participant: models_raid.RaidParticipant) -> None: +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") @@ -100,7 +100,7 @@ def _check_all_documents_accepted(participant: models_raid.RaidParticipant) -> N def _check_document_accepted( - document: models_raid.Document | None, + document: schemas_raid.Document | None, label: str, ) -> None: if document is None: @@ -116,7 +116,7 @@ def _check_document_accepted( async def _check_team_complete( - participant: models_raid.RaidParticipant, + participant: schemas_raid.RaidParticipant, db: AsyncSession, ) -> None: team = await cruds_raid.get_team_by_participant_id( @@ -129,7 +129,7 @@ async def _check_team_complete( status_code=400, detail="Participant is not in a team", ) - if team.second_id is None: + if team.second is None: raise HTTPException( status_code=400, detail="Team is missing a second member", @@ -147,7 +147,7 @@ async def _check_team_complete( async def check_volunteer_validation_consistency( - volunteer: models_raid.RaidVolunteer, + volunteer: schemas_raid.RaidVolunteer, edition_id, db: AsyncSession, ) -> None: @@ -176,7 +176,7 @@ async def check_volunteer_validation_consistency( def compute_participant_progress( - participant: models_raid.RaidParticipant, + participant: schemas_raid.RaidParticipant, ) -> float: """Pure port of the former RaidParticipant.validation_progress @property. @@ -235,7 +235,7 @@ def compute_participant_progress( return (number_validated / number_total) * 100 -def compute_team_progress(team: models_raid.RaidTeam) -> float: +def compute_team_progress(team: schemas_raid.RaidTeam) -> float: """Pure port of the former RaidTeam.validation_progress @property.""" number_validated = 0 number_total = 2 @@ -249,7 +249,7 @@ def compute_team_progress(team: models_raid.RaidTeam) -> float: ) * 0.45 -def count_total_required_documents(participant: models_raid.RaidParticipant) -> int: +def count_total_required_documents(participant: schemas_raid.RaidParticipant) -> int: number_total = 3 if participant.situation in (Situation.centrale, Situation.otherSchool): number_total += 1 @@ -258,7 +258,7 @@ def count_total_required_documents(participant: models_raid.RaidParticipant) -> return number_total -def count_accepted_documents(participant: models_raid.RaidParticipant) -> int: +def count_accepted_documents(participant: schemas_raid.RaidParticipant) -> int: number_validated = 0 if ( participant.situation in (Situation.centrale, Situation.otherSchool) diff --git a/tests/modules/raid/test_schemas_raid.py b/tests/modules/raid/test_schemas_raid.py index 578379a251..1442efd856 100644 --- a/tests/modules/raid/test_schemas_raid.py +++ b/tests/modules/raid/test_schemas_raid.py @@ -156,6 +156,7 @@ def test_team_preview_progress_with_no_participants() -> None: edition_id=uuid4(), name="T", number=None, + captain_id="u1", captain=schemas_raid.RaidParticipantPreview( user_id="u1", edition_id=uuid4(), @@ -179,6 +180,7 @@ def test_team_preview_progress_with_filled_meta_only() -> None: edition_id=uuid4(), name="T", number=42, + captain_id="u1", captain=schemas_raid.RaidParticipantPreview( user_id="u1", edition_id=uuid4(), diff --git a/tests/modules/raid/test_validation_checker.py b/tests/modules/raid/test_validation_checker.py index b0ce92a18b..83bf153d82 100644 --- a/tests/modules/raid/test_validation_checker.py +++ b/tests/modules/raid/test_validation_checker.py @@ -252,7 +252,7 @@ async def test_full_participant_checker_passes_for_valid_data() -> None: p = _make_validated_participant(edition_id=edition_id) team = Mock( spec=models_raid.RaidTeam, - second_id="other-id", + second=Mock(), difficulty=Difficulty.sports, meeting_place=MeetingPlace.centrale, ) @@ -279,7 +279,7 @@ async def test_full_participant_checker_fails_when_team_incomplete() -> None: p = _make_validated_participant(edition_id=edition_id) team_no_second = Mock( spec=models_raid.RaidTeam, - second_id=None, + second=None, difficulty=Difficulty.sports, meeting_place=MeetingPlace.centrale, ) diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index cdd00568b6..1567232e27 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -20,7 +20,7 @@ from app.core.groups import models_groups from app.core.groups.groups_type import AccountType from app.core.users import cruds_users, models_users, schemas_users -from app.modules.raid import coredata_raid, cruds_raid, models_raid +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.raid_type import ( Difficulty, @@ -912,7 +912,7 @@ async def test_volunteer_crud_roundtrip() -> None: await _set_user_identity(user.id, "+33600000001", datetime.date(1998, 1, 1)) async with get_TestingSessionLocal()() as db: - v = models_raid.RaidVolunteer( + v = schemas_raid.RaidVolunteerCreate( user_id=user.id, edition_id=active_edition.id, created_at=datetime.datetime.now(tz=datetime.UTC), @@ -954,7 +954,7 @@ async def test_volunteer_crud_roundtrip() -> None: @pytest.mark.asyncio async def test_edition_crud_create_read_delete() -> None: async with get_TestingSessionLocal()() as db: - new_edition = models_raid.RaidEdition( + new_edition = schemas_raid.RaidEdition( id=uuid.uuid4(), year=2019, name="Legacy", From 34444edfa0f0babe2c5b0d0ee9aff8b23e67eea6 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 5 Jun 2026 23:12:14 +0200 Subject: [PATCH 17/26] refacto(documents): more readable document progress calculation --- app/modules/raid/utils/validation_checker.py | 228 +++++++++++-------- 1 file changed, 129 insertions(+), 99 deletions(-) diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py index 7492a538fc..f24023c358 100644 --- a/app/modules/raid/utils/validation_checker.py +++ b/app/modules/raid/utils/validation_checker.py @@ -5,6 +5,9 @@ raises a distinct HTTPException so the frontend can i18n cleanly. """ +from collections.abc import Callable +from dataclasses import dataclass + from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession @@ -175,119 +178,146 @@ async def check_volunteer_validation_consistency( ) +# --------------------------------------------------------------------------- +# 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: - """Pure port of the former RaidParticipant.validation_progress @property. + """Return the participant's registration progress as a 0-100 percentage. - Kept as a read-only helper so the frontend can display a percentage while - RaidRegistrationStatus remains the actual source of truth. + 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`. """ - number_total = 10 - conditions = [ - participant.address, - participant.bike_size, - participant.t_shirt_size, - participant.situation, - participant.attestation_on_honour, - ] - number_validated: float = sum(condition is not None for condition in conditions) - if participant.situation in (Situation.centrale, Situation.otherSchool): - number_total += 1 - if ( - participant.student_card - and participant.student_card.validation == DocumentValidation.accepted - ): - number_validated += 1 - if participant.is_minor: - number_total += 1 - if participant.parent_authorization: - if participant.parent_authorization.validation == DocumentValidation.accepted: - number_validated += 1 - elif ( - participant.parent_authorization.validation - == DocumentValidation.temporary - ): - number_validated += 0.5 - if ( - participant.id_card - and participant.id_card.validation == DocumentValidation.accepted - ): - number_validated += 1 - if participant.medical_certificate: - if participant.medical_certificate.validation == DocumentValidation.accepted: - number_validated += 1 - elif ( - participant.medical_certificate.validation == DocumentValidation.temporary - ): - number_validated += 0.5 - if participant.security_file: - security_validation = participant.security_file.validation - if security_validation == DocumentValidation.accepted: - number_validated += 1 - elif security_validation == DocumentValidation.temporary: - number_validated += 0.5 - if ( - participant.raid_rules - and participant.raid_rules.validation == DocumentValidation.accepted - ): - number_validated += 1 - return (number_validated / number_total) * 100 + 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: - """Pure port of the former RaidTeam.validation_progress @property.""" - number_validated = 0 - number_total = 2 - if team.difficulty: - number_validated += 1 - if team.meeting_place: - number_validated += 1 - return (number_validated / number_total) * 10 + ( - compute_participant_progress(team.captain) - + (compute_participant_progress(team.second) if team.second else 0) - ) * 0.45 + """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_total = 3 - if participant.situation in (Situation.centrale, Situation.otherSchool): - number_total += 1 - if participant.is_minor: - number_total += 1 - return number_total + """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_validated = 0 - if ( - participant.situation in (Situation.centrale, Situation.otherSchool) - and participant.student_card - and participant.student_card.validation == DocumentValidation.accepted - ): - number_validated += 1 - if ( - participant.id_card - and participant.id_card.validation == DocumentValidation.accepted - ): - number_validated += 1 - if ( - participant.medical_certificate - and participant.medical_certificate.validation == DocumentValidation.accepted - ): - number_validated += 1 - if ( - participant.raid_rules - and participant.raid_rules.validation == DocumentValidation.accepted - ): - number_validated += 1 - if ( - participant.is_minor - and participant.parent_authorization - and participant.parent_authorization.validation == DocumentValidation.accepted - ): - number_validated += 1 - return number_validated + """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__ = [ From 6b63632425af2d896df1ac499fa252f95833844f Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 5 Jun 2026 23:16:33 +0200 Subject: [PATCH 18/26] fix(format): resolving ruff conflict rule on comma --- app/modules/raid/cruds_raid.py | 8 +-- app/modules/raid/endpoints_raid.py | 5 +- app/modules/raid/schemas_raid.py | 1 - app/modules/raid/utils/utils_raid.py | 12 ++--- app/modules/raid/utils/validation_checker.py | 8 +-- .../versions/59-raid_editions_and_state.py | 5 +- tests/modules/raid/test_utils_raid.py | 33 +++++++----- tests/modules/raid/test_validation_checker.py | 32 +++++++---- tests/modules/test_raid.py | 54 ++++++++++++++----- 9 files changed, 99 insertions(+), 59 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index a4bc360b02..67d042b84a 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -742,9 +742,7 @@ async def get_participant_checkout_by_checkout_id( ), ) model = checkout.scalars().first() - return ( - schemas_raid.RaidParticipantCheckout.model_validate(model) if model else None - ) + return schemas_raid.RaidParticipantCheckout.model_validate(model) if model else None # --- Edition CRUDs ------------------------------------------------------ @@ -754,9 +752,7 @@ async def get_all_editions( db: AsyncSession, ) -> list[schemas_raid.RaidEdition]: result = await db.execute(select(models_raid.RaidEdition)) - return [ - schemas_raid.RaidEdition.model_validate(e) for e in result.scalars().all() - ] + return [schemas_raid.RaidEdition.model_validate(e) for e in result.scalars().all()] async def get_edition_by_id( diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index f4f1a07b8b..22645966de 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -675,9 +675,8 @@ async def read_document( 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) + 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, diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index f4d37b45bc..8a32966710 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -226,7 +226,6 @@ class RaidTeamPreview(RaidTeamBase): @computed_field # type: ignore[prop-decorator] @property def validation_progress(self) -> float: - captain_progress = ( self.captain.validation_progress if isinstance(self.captain, RaidParticipant) diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index ee2e757be6..db9ed3f730 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -49,9 +49,7 @@ def will_birthday_be_minor_on( day=1, ) - return ( - date(birthday.year + 18, birthday.month, birthday.day) > raid_start_date - ) + return date(birthday.year + 18, birthday.month, birthday.day) > raid_start_date async def validate_payment( @@ -119,9 +117,7 @@ async def set_team_number( Difficulty.expert: 200, } new_team_number = ( - difficulty_separator[team.difficulty] + 1 - if not max_number - else max_number + 1 + 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) @@ -230,9 +226,7 @@ async def get_all_security_files_zip( 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: for team in teams: - for participant in [team.captain] + ( - [team.second] if team.second else [] - ): + for participant in [team.captain] + ([team.second] if team.second else []): file_id = await generate_security_file_pdf( participant, information, diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py index f24023c358..8661f0f980 100644 --- a/app/modules/raid/utils/validation_checker.py +++ b/app/modules/raid/utils/validation_checker.py @@ -169,9 +169,7 @@ async def check_volunteer_validation_consistency( status_code=400, detail="Volunteer emergency contact is incomplete", ) - if volunteer.has_car and ( - volunteer.car_seats is None or volunteer.car_seats <= 0 - ): + 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", @@ -219,7 +217,9 @@ class _DocumentRule: _DOCUMENT_RULES: tuple[_DocumentRule, ...] = ( _DocumentRule("id_card", applies=lambda _c: True), _DocumentRule( - "medical_certificate", applies=lambda _c: True, counts_temporary=True, + "medical_certificate", + applies=lambda _c: True, + counts_temporary=True, ), _DocumentRule( "security_file", diff --git a/migrations/versions/59-raid_editions_and_state.py b/migrations/versions/59-raid_editions_and_state.py index b00154ced2..fc231db6a7 100644 --- a/migrations/versions/59-raid_editions_and_state.py +++ b/migrations/versions/59-raid_editions_and_state.py @@ -377,7 +377,10 @@ def downgrade() -> None: 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("firstname", sa.String(), nullable=True), + ) op.add_column("raid_participant", sa.Column("name", sa.String(), nullable=True)) conn.execute( sa.text( diff --git a/tests/modules/raid/test_utils_raid.py b/tests/modules/raid/test_utils_raid.py index e4d0ee07e4..1a47713aa1 100644 --- a/tests/modules/raid/test_utils_raid.py +++ b/tests/modules/raid/test_utils_raid.py @@ -37,28 +37,37 @@ def test_minor_when_birthday_unknown() -> None: 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 + 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 + 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 + assert ( + will_birthday_be_minor_on( + eighteenth_birthday_after_raid, + raid_date, + ) + is True + ) # -- calculate_raid_payment (new enum semantics) --------------------------- diff --git a/tests/modules/raid/test_validation_checker.py b/tests/modules/raid/test_validation_checker.py index 83bf153d82..ddb8508843 100644 --- a/tests/modules/raid/test_validation_checker.py +++ b/tests/modules/raid/test_validation_checker.py @@ -343,9 +343,7 @@ async def test_check_volunteer_rejects_wrong_edition() -> None: uuid4(), AsyncMock(), ) - assert exc_info.value.detail == ( - "Volunteer does not belong to the current edition" - ) + assert exc_info.value.detail == ("Volunteer does not belong to the current edition") @pytest.mark.asyncio @@ -362,11 +360,11 @@ async def test_check_volunteer_rejects_missing_phone() -> None: ) with pytest.raises(HTTPException) as exc_info: await validation_checker.check_volunteer_validation_consistency( - v, eid, AsyncMock(), + v, + eid, + AsyncMock(), ) - assert exc_info.value.detail == ( - "Volunteer phone is not set on the user profile" - ) + assert exc_info.value.detail == ("Volunteer phone is not set on the user profile") @pytest.mark.asyncio @@ -383,7 +381,9 @@ async def test_check_volunteer_rejects_missing_emergency_contact() -> None: ) with pytest.raises(HTTPException) as exc_info: await validation_checker.check_volunteer_validation_consistency( - v, eid, AsyncMock(), + v, + eid, + AsyncMock(), ) assert exc_info.value.detail == "Volunteer emergency contact is incomplete" @@ -403,7 +403,9 @@ async def test_check_volunteer_passes_for_complete_profile() -> None: car_seats=None, ) await validation_checker.check_volunteer_validation_consistency( - v, eid, AsyncMock(), + v, + eid, + AsyncMock(), ) @@ -457,7 +459,11 @@ def test_compute_participant_progress_partial_gives_fraction() -> None: def test_count_total_required_documents_centrale() -> None: - p = Mock(spec=models_raid.RaidParticipant, situation=Situation.centrale, is_minor=False) + p = Mock( + spec=models_raid.RaidParticipant, + situation=Situation.centrale, + is_minor=False, + ) assert validation_checker.count_total_required_documents(p) == 4 @@ -467,7 +473,11 @@ def test_count_total_required_documents_other_minor() -> None: def test_count_total_required_documents_centrale_minor() -> None: - p = Mock(spec=models_raid.RaidParticipant, situation=Situation.centrale, is_minor=True) + p = Mock( + spec=models_raid.RaidParticipant, + situation=Situation.centrale, + is_minor=True, + ) assert validation_checker.count_total_required_documents(p) == 5 diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index 1567232e27..cfe6891888 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -144,7 +144,11 @@ async def init_objects(client) -> None: 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)) + 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 @@ -164,12 +168,20 @@ async def init_objects(client) -> None: 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)) + 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)) + 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 @@ -878,10 +890,14 @@ async def test_get_all_participants_scoped_by_edition() -> None: 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, + user_second.id, + active_edition.id, + db, ) assert not await cruds_raid.is_user_a_participant( - user_no_raid.id, active_edition.id, db, + user_no_raid.id, + active_edition.id, + db, ) @@ -889,10 +905,14 @@ async def test_is_user_a_participant_true_and_false() -> None: 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, + user_captain.id, + active_edition.id, + db, ) second_team = await cruds_raid.get_team_by_participant_id( - user_second.id, active_edition.id, db, + user_second.id, + active_edition.id, + db, ) assert captain_team is not None assert second_team is not None @@ -923,29 +943,39 @@ async def test_volunteer_crud_roundtrip() -> None: await db.commit() async with get_TestingSessionLocal()() as db: got = await cruds_raid.get_volunteer_by_user_id( - user.id, active_edition.id, db, + 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, + 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, + 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, + 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, + user.id, + active_edition.id, + db, ) assert re_read is not None assert re_read.validated is True From c85de9d3e235f9c7af84fdccdcc2c730125a13e3 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Sat, 6 Jun 2026 00:24:24 +0200 Subject: [PATCH 19/26] fix(migration): reordering after rebase --- migrations/versions/59-raid_editions_and_state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/59-raid_editions_and_state.py b/migrations/versions/59-raid_editions_and_state.py index fc231db6a7..4ea9777c06 100644 --- a/migrations/versions/59-raid_editions_and_state.py +++ b/migrations/versions/59-raid_editions_and_state.py @@ -17,7 +17,7 @@ # revision identifiers, used by Alembic. revision: str = "9e1a4b2d7f10" -down_revision: str | None = "e58ffcd6b9eb" +down_revision: str | None = "7dbe3290e145" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 0c18cef6991160f9c5c0e215bd823a3148947c3e Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Wed, 10 Jun 2026 09:19:26 +0200 Subject: [PATCH 20/26] fix(raid): cancelled records no longer block re-registration on the other track Co-Authored-By: Claude Opus 4.7 (1M context) --- app/modules/raid/dependencies_raid.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/modules/raid/dependencies_raid.py b/app/modules/raid/dependencies_raid.py index 8862d25ab6..53a0a81d3a 100644 --- a/app/modules/raid/dependencies_raid.py +++ b/app/modules/raid/dependencies_raid.py @@ -12,6 +12,7 @@ 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( @@ -50,8 +51,13 @@ async def ensure_user_is_not_participant_in_edition( 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: + if ( + participant is not None + and participant.status != RaidRegistrationStatus.cancelled + ): raise HTTPException( status_code=400, detail="User is already a participant in this edition", @@ -63,8 +69,9 @@ async def ensure_user_is_not_volunteer_in_edition( 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: + if volunteer is not None and not volunteer.cancelled: raise HTTPException( status_code=400, detail="User is already a volunteer in this edition", From a4e7a1febd9a200c3f384cf55ac41b0425a575eb Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Wed, 10 Jun 2026 09:19:26 +0200 Subject: [PATCH 21/26] fix(raid): cancelled participants and volunteers no longer block edition deletion Co-Authored-By: Claude Opus 4.7 (1M context) --- app/modules/raid/endpoints_raid.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 22645966de..e28299d655 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -163,12 +163,23 @@ async def delete_edition( 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) - if participants: + 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) From 6f97774a1a035676d153c814e58405b506ee803b Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Tue, 23 Jun 2026 15:42:57 +0200 Subject: [PATCH 22/26] fix: importing at the top of the file --- app/modules/raid/endpoints_raid.py | 2 +- app/modules/raid/schemas_raid.py | 17 +++++------------ app/modules/raid/utils/utils_raid.py | 3 +-- app/modules/raid/utils/validation_checker.py | 16 +++++++++++++--- .../versions/59-raid_editions_and_state.py | 3 +-- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index e28299d655..685c968629 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 diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 8a32966710..63790f5087 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -19,6 +19,11 @@ Situation, Size, ) +from app.modules.raid.utils.validation_checker import ( + compute_participant_progress, + count_accepted_documents, + count_total_required_documents, +) class DocumentBase(BaseModel): @@ -138,28 +143,16 @@ class RaidParticipant(RaidParticipantPreview): @computed_field # type: ignore[prop-decorator] @property def validation_progress(self) -> float: - from app.modules.raid.utils.validation_checker import ( - compute_participant_progress, - ) - return compute_participant_progress(self) @computed_field # type: ignore[prop-decorator] @property def number_of_document(self) -> int: - from app.modules.raid.utils.validation_checker import ( - count_total_required_documents, - ) - return count_total_required_documents(self) @computed_field # type: ignore[prop-decorator] @property def number_of_validated_document(self) -> int: - from app.modules.raid.utils.validation_checker import ( - count_accepted_documents, - ) - return count_accepted_documents(self) diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index db9ed3f730..6c7cdfde71 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -16,6 +16,7 @@ get_meeting_place_label, nullable_number_to_string, ) +from app.modules.raid.utils.validation_checker import compute_team_progress from app.utils.tools import ( generate_pdf_from_template, get_core_data, @@ -167,8 +168,6 @@ async def generate_security_file_pdf( async def generate_recap_file_pdf( team: schemas_raid.RaidTeam, ): - from app.modules.raid.utils.validation_checker import compute_team_progress - context = { "team_name": team.name, "parcours": get_difficulty_label(team.difficulty), diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py index 8661f0f980..8741f79460 100644 --- a/app/modules/raid/utils/validation_checker.py +++ b/app/modules/raid/utils/validation_checker.py @@ -5,13 +5,14 @@ raises a distinct HTTPException so the frontend can i18n cleanly. """ -from collections.abc import Callable +from __future__ import annotations + from dataclasses import dataclass +from typing import TYPE_CHECKING from fastapi import HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.raid import cruds_raid, schemas_raid +from app.modules.raid import cruds_raid from app.modules.raid.raid_type import ( DocumentValidation, RaidRegistrationStatus, @@ -19,6 +20,15 @@ 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, diff --git a/migrations/versions/59-raid_editions_and_state.py b/migrations/versions/59-raid_editions_and_state.py index 4ea9777c06..c1f6427b75 100644 --- a/migrations/versions/59-raid_editions_and_state.py +++ b/migrations/versions/59-raid_editions_and_state.py @@ -4,6 +4,7 @@ """ import contextlib +import json import uuid from collections.abc import Sequence from enum import Enum @@ -66,8 +67,6 @@ def upgrade() -> None: ), ).first() if raid_info_row is not None: - import json - try: payload = json.loads(raid_info_row[0]) if raid_info_row[0] else {} raid_start_date = payload.get("raid_start_date") From a3d93719c0add048e7cea37d12ddcc05f531750a Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Tue, 23 Jun 2026 19:07:25 +0200 Subject: [PATCH 23/26] fix: linter --- tests/modules/raid/test_schemas_raid.py | 5 ++--- tests/modules/raid/test_validation_checker.py | 17 ++--------------- tests/modules/test_raid.py | 3 +-- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/tests/modules/raid/test_schemas_raid.py b/tests/modules/raid/test_schemas_raid.py index 1442efd856..5d44238a37 100644 --- a/tests/modules/raid/test_schemas_raid.py +++ b/tests/modules/raid/test_schemas_raid.py @@ -13,6 +13,8 @@ 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, @@ -200,9 +202,6 @@ def test_team_preview_progress_with_filled_meta_only() -> None: def _dummy_core_user(uid: str): - from app.core.groups.groups_type import AccountType - from app.core.users.schemas_users import CoreUser - return CoreUser( id=uid, email=f"{uid}@example.com", diff --git a/tests/modules/raid/test_validation_checker.py b/tests/modules/raid/test_validation_checker.py index ddb8508843..752197eeb1 100644 --- a/tests/modules/raid/test_validation_checker.py +++ b/tests/modules/raid/test_validation_checker.py @@ -9,13 +9,13 @@ # ruff: noqa: SLF001 # tests deliberately exercise private sub-checks -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock from uuid import uuid4 import pytest from fastapi import HTTPException -from app.modules.raid import models_raid +from app.modules.raid import cruds_raid, models_raid from app.modules.raid.raid_type import ( Difficulty, DocumentValidation, @@ -246,7 +246,6 @@ def test_check_all_documents_accepted_rejects_missing_id_card() -> None: @pytest.mark.asyncio async def test_full_participant_checker_passes_for_valid_data() -> None: - from unittest.mock import AsyncMock edition_id = uuid4() p = _make_validated_participant(edition_id=edition_id) @@ -257,8 +256,6 @@ async def test_full_participant_checker_passes_for_valid_data() -> None: meeting_place=MeetingPlace.centrale, ) - from app.modules.raid import cruds_raid - original = cruds_raid.get_team_by_participant_id cruds_raid.get_team_by_participant_id = AsyncMock(return_value=team) try: @@ -273,7 +270,6 @@ async def test_full_participant_checker_passes_for_valid_data() -> None: @pytest.mark.asyncio async def test_full_participant_checker_fails_when_team_incomplete() -> None: - from unittest.mock import AsyncMock edition_id = uuid4() p = _make_validated_participant(edition_id=edition_id) @@ -284,8 +280,6 @@ async def test_full_participant_checker_fails_when_team_incomplete() -> None: meeting_place=MeetingPlace.centrale, ) - from app.modules.raid import cruds_raid - original = cruds_raid.get_team_by_participant_id cruds_raid.get_team_by_participant_id = AsyncMock(return_value=team_no_second) try: @@ -302,13 +296,10 @@ async def test_full_participant_checker_fails_when_team_incomplete() -> None: @pytest.mark.asyncio async def test_full_participant_checker_fails_when_no_team() -> None: - from unittest.mock import AsyncMock edition_id = uuid4() p = _make_validated_participant(edition_id=edition_id) - from app.modules.raid import cruds_raid - original = cruds_raid.get_team_by_participant_id cruds_raid.get_team_by_participant_id = AsyncMock(return_value=None) try: @@ -328,7 +319,6 @@ async def test_full_participant_checker_fails_when_no_team() -> None: @pytest.mark.asyncio async def test_check_volunteer_rejects_wrong_edition() -> None: - from unittest.mock import AsyncMock v = Mock( spec=models_raid.RaidVolunteer, @@ -348,7 +338,6 @@ async def test_check_volunteer_rejects_wrong_edition() -> None: @pytest.mark.asyncio async def test_check_volunteer_rejects_missing_phone() -> None: - from unittest.mock import AsyncMock eid = uuid4() v = Mock( @@ -369,7 +358,6 @@ async def test_check_volunteer_rejects_missing_phone() -> None: @pytest.mark.asyncio async def test_check_volunteer_rejects_missing_emergency_contact() -> None: - from unittest.mock import AsyncMock eid = uuid4() v = Mock( @@ -390,7 +378,6 @@ async def test_check_volunteer_rejects_missing_emergency_contact() -> None: @pytest.mark.asyncio async def test_check_volunteer_passes_for_complete_profile() -> None: - from unittest.mock import AsyncMock eid = uuid4() v = Mock( diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index cfe6891888..d9c67d1c46 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -31,6 +31,7 @@ Situation, Size, ) +from app.types.sqlalchemy import Base from tests.commons import ( add_account_type_permission, add_coredata_to_db, @@ -86,8 +87,6 @@ async def _ensure_tables_created() -> None: pytest process isn't selected as the "chosen worker" by psutil. Force table creation so init_objects never races with it. """ - from app.types.sqlalchemy import Base - session_local = get_TestingSessionLocal() async with session_local() as db: engine = db.bind From 49ebe943c2a40ac2c026fcbc42079498c5413b77 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Wed, 22 Jul 2026 21:33:06 +0200 Subject: [PATCH 24/26] feat(raid): expose participants list Adds GET /raid/participants (manage_raid-gated), mirroring GET /raid/volunteers, built on the existing get_all_participants CRUD with an optional status filter. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/modules/raid/endpoints_raid.py | 16 ++++++++++++++++ tests/modules/test_raid.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 685c968629..efae7cbbb3 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -1274,6 +1274,22 @@ async def get_my_volunteer( 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], diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index d9c67d1c46..b27f78978a 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -359,6 +359,28 @@ def test_get_participant_as_admin(client: TestClient) -> None: assert r.status_code == 200 +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 r.status_code == 403 + + +def test_list_participants_as_admin(client: TestClient) -> None: + r = client.get( + "/raid/participants", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + 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_create_participant_missing_identity_400(client: TestClient) -> None: r = client.post( "/raid/participants", From 5399155b555b254b135d619d1009f78e26b0e59d Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Wed, 22 Jul 2026 22:06:17 +0200 Subject: [PATCH 25/26] fix(migration): reorder raid chain to follow Documenso head Documenso PR #1023 landed on main with 60-documents.py (parent 7dbe3290e145), colliding with our raid_editions_and_state which targeted the same parent. Re-parent raid_editions_and_state onto 84ee3296cc58 so alembic sees a single head again. Co-Authored-By: Claude Opus 4.7 --- ...raid_editions_and_state.py => 61-raid_editions_and_state.py} | 2 +- .../versions/{60-raid_volunteers.py => 62-raid_volunteers.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{59-raid_editions_and_state.py => 61-raid_editions_and_state.py} (99%) rename migrations/versions/{60-raid_volunteers.py => 62-raid_volunteers.py} (100%) diff --git a/migrations/versions/59-raid_editions_and_state.py b/migrations/versions/61-raid_editions_and_state.py similarity index 99% rename from migrations/versions/59-raid_editions_and_state.py rename to migrations/versions/61-raid_editions_and_state.py index c1f6427b75..d2332cd51c 100644 --- a/migrations/versions/59-raid_editions_and_state.py +++ b/migrations/versions/61-raid_editions_and_state.py @@ -18,7 +18,7 @@ # revision identifiers, used by Alembic. revision: str = "9e1a4b2d7f10" -down_revision: str | None = "7dbe3290e145" +down_revision: str | None = "84ee3296cc58" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/migrations/versions/60-raid_volunteers.py b/migrations/versions/62-raid_volunteers.py similarity index 100% rename from migrations/versions/60-raid_volunteers.py rename to migrations/versions/62-raid_volunteers.py From cda594dad597842034799b9cbc12193eb900e044 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Wed, 22 Jul 2026 22:29:22 +0200 Subject: [PATCH 26/26] ci(test): raise Postgres lock-table sizing for 8 parallel workers The 8 xdist workers each drop and re-create ~126 tables plus indexes and FKs in a single transaction, which brushes past the default max_locks_per_transaction=64 * max_connections=100 lock-table size and sporadically fails with "out of shared memory / You might need to increase max_locks_per_transaction". Append the tuned values to postgresql.conf, restart the service container, and wait for readiness before running tests. GHA services can't set command args directly, so we name the container and docker exec into it after startup. --- .github/workflows/test.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb444b3d3b..7e18ff203d 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 @@ -55,6 +56,21 @@ jobs: - name: Fetch full main branch history # We don't known the the base commit of the PR run: git fetch origin main --unshallow + # 8 xdist workers each run `create_all` on ~126 tables + ~150 indexes + FKs + # in a single transaction, which brushes past the default max_locks_per_transaction=64. + # Bump lock-table sizing before tests. GHA services can't set command args directly, + # so we exec into the named container, append config, and restart. + - name: Tune Postgres for parallel test workers + run: | + docker exec postgres sh -c "printf 'max_locks_per_transaction = 512\nmax_connections = 200\n' >> /var/lib/postgresql/data/postgresql.conf" + docker restart postgres + for _ in $(seq 1 30); do + if docker exec postgres pg_isready -U hyperion -d hyperion; then exit 0; fi + sleep 1 + done + echo "postgres did not become ready after restart" >&2 + exit 1 + # Setup Python (faster than using Python container) - name: Setup Python uses: actions/setup-python@v6