From 640f741e99fee3de50e59d0e86746046036b7fa1 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:11:56 +0200 Subject: [PATCH 01/36] feat: adding consent fields to model --- app/modules/raid/models_raid.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/modules/raid/models_raid.py b/app/modules/raid/models_raid.py index 4ae59c132f..465bf76b0f 100644 --- a/app/modules/raid/models_raid.py +++ b/app/modules/raid/models_raid.py @@ -72,6 +72,8 @@ class SecurityFile(Base): emergency_person_name: Mapped[str | None] emergency_person_phone: Mapped[str | None] file_id: Mapped[str | None] + consent_given: Mapped[bool] = mapped_column(default=False) + consent_given_at: Mapped[datetime | None] = mapped_column(default=None) @property def validation(self) -> DocumentValidation: From 8c0d894e1666784f24e916a65240c3847509cd49 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:12:07 +0200 Subject: [PATCH 02/36] feat(migration): adding column do db --- .../versions/66-raid-security-file-consent.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 migrations/versions/66-raid-security-file-consent.py diff --git a/migrations/versions/66-raid-security-file-consent.py b/migrations/versions/66-raid-security-file-consent.py new file mode 100644 index 0000000000..c66dd836b4 --- /dev/null +++ b/migrations/versions/66-raid-security-file-consent.py @@ -0,0 +1,52 @@ +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from app.types.sqlalchemy import TZDateTime + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e0e6f306bed7" +down_revision: str | None = "dd905b1f5f57" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + + op.add_column( + "raid_security_file", + sa.Column( + "consent_given", + sa.Boolean(), + nullable=False, + server_default="False", + ), + ) + op.add_column( + "raid_security_file", + sa.Column("consent_given_at", TZDateTime(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("raid_security_file", "consent_given") + op.drop_column("raid_security_file", "consent_given_at") + + +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 3e0b56800da9d154022e509c49fcb5461056c12f Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:13:07 +0200 Subject: [PATCH 03/36] feat: adding consent to schema --- app/modules/raid/schemas_raid.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 163b6bbc15..2e70d830bc 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -63,6 +63,8 @@ class SecurityFileBase(BaseModel): emergency_person_name: str | None = None emergency_person_phone: str | None = None file_id: str | None = None + consent_given: bool = False + consent_given_at: datetime | None = None class SecurityFile(SecurityFileBase): From 1f4890906e7291a7d52da6807fe6575026c3c504 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:13:34 +0200 Subject: [PATCH 04/36] feat: excluding security file when pulling all participants --- app/modules/raid/cruds_raid.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index 8db6ddef8a..3bdc89949c 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -58,10 +58,16 @@ async def get_all_participants( if status is not None: stmt = stmt.where(models_raid.RaidParticipant.status == status) participants = await db.execute(stmt) - return [ - schemas_raid.RaidParticipant.model_validate(p) - for p in participants.scalars().all() - ] + + # Remove security_file from the participants list to avoid including it in the response. + found_participants = participants.scalars().all() + cleaned_participants = [] + for p in found_participants: + participant = schemas_raid.RaidParticipant.model_validate(p) + participant.security_file = None + cleaned_participants.append(participant) + + return cleaned_participants async def update_participant( From 6337111b0b886e8b58a60736bfceb343f56be91f Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:15:22 +0200 Subject: [PATCH 05/36] feat: adding consent when creating security file --- app/modules/raid/cruds_raid.py | 2 ++ app/modules/raid/endpoints_raid.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index 3bdc89949c..aaf991f80b 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -378,6 +378,8 @@ async def add_security_file( emergency_person_name=security_file.emergency_person_name, emergency_person_phone=security_file.emergency_person_phone, file_id=security_file.file_id, + consent_given=security_file.consent_given, + consent_given_at=security_file.consent_given_at, ), ) await db.flush() diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 5a50b21fa2..183fac8605 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -787,6 +787,8 @@ async def set_security_file( emergency_person_name=security_file.emergency_person_name, emergency_person_phone=security_file.emergency_person_phone, file_id=security_file.file_id, + consent_given=security_file.consent_given, + consent_given_at=datetime.now(UTC), ) await cruds_raid.add_security_file(security_file_schema, edition.id, db) await cruds_raid.assign_security_file( From 4f113ea57575ab8c3f84b6fe36f6b685be4b4d9f Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:19:48 +0200 Subject: [PATCH 06/36] feat: gating security file update based on consent only --- app/modules/raid/endpoints_raid.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 183fac8605..09cb1c2f67 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -740,24 +740,17 @@ async def set_security_file( ), edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): - """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.") + if user.id != participant_id: + raise HTTPException(status_code=403, detail="You are not the participant.") participant = await get_participant_or_404(participant_id, edition.id, db) + if not security_file.consent_given: + raise HTTPException( + status_code=400, + detail="Consent must be given to register medical data", + ) + if participant.security_file_id: await cruds_raid.update_security_file( security_file_id=participant.security_file_id, From accc9f33cb2e450c0dd5d252c5ccc905f789cd9f Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:20:55 +0200 Subject: [PATCH 07/36] feat: adding medical read permission and logging --- app/modules/raid/endpoints_raid.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 09cb1c2f67..4784f9b05e 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -57,11 +57,13 @@ ) hyperion_error_logger = logging.getLogger("hyperion.error") +hyperion_security_logger = logging.getLogger("hyperion.security") class RaidPermissions(ModulePermissions): access_raid = "access_raid" manage_raid = "manage_raid" + read_medical_data = "read_medical_data" module = Module( @@ -1153,8 +1155,30 @@ async def download_security_files_zip( ), edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): + has_medical_permission = await has_user_permission( + user, + RaidPermissions.read_medical_data, + db, + ) + + if not has_medical_permission: + raise HTTPException( + status_code=403, + detail="You don't have the permisison to have the data of the participants.", + ) + information = await get_core_data(coredata_raid.RaidInformation, db) zip_file_path = await get_all_security_files_zip(db, information, edition.id) + + hyperion_security_logger.info( + "Medical data access", + extra={ + "accessed_by_user_id": user.id, + "edition_id": str(edition.id), + "access_type": "download", + }, + ) + return FileResponse( zip_file_path, media_type="application/zip", From ae083558900dc42e704210eaa87cac36d526f7e9 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:21:28 +0200 Subject: [PATCH 08/36] feat: removing security file on admin access of a participant --- app/modules/raid/endpoints_raid.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 4784f9b05e..92cf5a8389 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -204,16 +204,26 @@ async def get_participant_by_id( ), edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): - if user_id != user.id and not await has_user_permission( + is_owner = user.id == user_id + is_admin = await has_user_permission( user, RaidPermissions.manage_raid, db, - ): + ) + + if not is_owner and not is_admin: raise HTTPException( status_code=403, detail="You can not get data of another user", ) - return await get_participant_or_404(user_id, edition.id, db) + + participant = await get_participant_or_404(user_id, edition.id, db) + + # If the user is not the owner, hide its security file from the response + if not is_owner: + participant.security_file = None + + return participant @module.router.post( From 18c61cdd492194460776cecbf7d1b9f1cab44ac5 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:36:56 +0200 Subject: [PATCH 09/36] feat: adding course responsible as emergency contact --- app/modules/raid/coredata_raid.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/modules/raid/coredata_raid.py b/app/modules/raid/coredata_raid.py index 19e043fa03..b7cc134546 100644 --- a/app/modules/raid/coredata_raid.py +++ b/app/modules/raid/coredata_raid.py @@ -13,6 +13,7 @@ class RaidInformation(core_data.BaseCoreData): president: EmergencyContact | None = None volunteer_responsible: EmergencyContact | None = None security_responsible: EmergencyContact | None = None + course_responsible: EmergencyContact | None = None rescue: EmergencyContact | None = None raid_rules_id: str | None = None raid_information_id: str | None = None From 43a62d1b66ef18f7e4c4c69d8acd0f3da3749d54 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:37:21 +0200 Subject: [PATCH 10/36] feat: generating security file with course responsible data --- app/modules/raid/utils/utils_raid.py | 3 +++ assets/templates/raid_security_file.html | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index d2f139acac..5fb54bd505 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -151,6 +151,9 @@ async def generate_security_file_pdf( "volunteer_responsible": information.volunteer_responsible.__dict__ if information.volunteer_responsible else None, + "course_responsible": information.course_responsible.__dict__ + if information.course_responsible + else None, "team_number": team_number, } diff --git a/assets/templates/raid_security_file.html b/assets/templates/raid_security_file.html index 6904fa9bd9..940f75593f 100644 --- a/assets/templates/raid_security_file.html +++ b/assets/templates/raid_security_file.html @@ -314,6 +314,16 @@ if security_responsible else "Aucun"}} + + + Responsable Parcours + + + {{course_responsible.firstname + " " + course_responsible.name + + " - +" + course_responsible.phone if course_responsible else + "Aucun"}} + + Président·e From 35997e238951ded8c261d99cede58aae64fab499 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 13:37:40 +0200 Subject: [PATCH 11/36] feat: updating testing setup with mocked course responsible --- tests/modules/raid/test_pdf_generation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/modules/raid/test_pdf_generation.py b/tests/modules/raid/test_pdf_generation.py index cbbf121018..6d7e58ebc5 100644 --- a/tests/modules/raid/test_pdf_generation.py +++ b/tests/modules/raid/test_pdf_generation.py @@ -67,6 +67,7 @@ def _create_mock_information() -> MagicMock: info.rescue = None info.security_responsible = None info.volunteer_responsible = None + info.course_responsible = None return info From d256ba8e06f918642b225a199bb3cd7b5f8df37f Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 14:40:54 +0200 Subject: [PATCH 12/36] feat: adding consent default data for test --- tests/modules/raid/test_security_file_fk.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/modules/raid/test_security_file_fk.py b/tests/modules/raid/test_security_file_fk.py index 59de5143cb..8484fe4f06 100644 --- a/tests/modules/raid/test_security_file_fk.py +++ b/tests/modules/raid/test_security_file_fk.py @@ -91,6 +91,7 @@ def _create_mock_security_file_base() -> schemas_raid.SecurityFileBase: emergency_person_name="Doe", emergency_person_phone="0600000000", file_id=None, + consent_given=True, ) From 85bff35cdd9107745f6c73740bfb29521f53ac18 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 17:26:16 +0200 Subject: [PATCH 13/36] feat: renaming is admin to is raid admin --- app/modules/raid/endpoints_raid.py | 48 +++++++++++++++--------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 92cf5a8389..ad168582fe 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -205,13 +205,13 @@ async def get_participant_by_id( edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): is_owner = user.id == user_id - is_admin = await has_user_permission( + is_raid_admin = await has_user_permission( user, RaidPermissions.manage_raid, db, ) - if not is_owner and not is_admin: + if not is_owner and not is_raid_admin: raise HTTPException( status_code=403, detail="You can not get data of another user", @@ -281,10 +281,10 @@ async def update_participant( ): saved_participant = await get_participant_or_404(user_id, edition.id, db) - is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) - if user.id != user_id and not is_admin: + is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) + if user.id != user_id and not is_raid_admin: raise HTTPException(status_code=403, detail="You are not the participant.") - if not is_admin and saved_participant.status != RaidRegistrationStatus.draft: + if not is_raid_admin and saved_participant.status != RaidRegistrationStatus.draft: raise HTTPException( status_code=400, detail="Participant is not in draft state; reopen first", @@ -373,11 +373,11 @@ async def reopen_participant( 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: + is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) + if user_id != user.id and not is_raid_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: + if participant.status == RaidRegistrationStatus.validated and not is_raid_admin: raise HTTPException( status_code=403, detail="Cannot reopen a validated participant", @@ -424,11 +424,11 @@ async def cancel_participant( ), edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): - is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) + is_raid_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: + if user_id != user.id and not is_raid_admin: raise HTTPException(status_code=403, detail="You are not the participant.") - if participant.status == RaidRegistrationStatus.validated and not is_admin: + if participant.status == RaidRegistrationStatus.validated and not is_raid_admin: raise HTTPException( status_code=403, detail="Only admins can cancel a validated participant", @@ -550,10 +550,10 @@ async def update_team( edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): 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: + is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) + if existing_team is None and not is_raid_admin: raise HTTPException(status_code=404, detail="Team not found.") - if existing_team is not None and existing_team.id != team_id and not is_admin: + if existing_team is not None and existing_team.id != team_id and not is_raid_admin: raise HTTPException(status_code=403, detail="You can only edit your own team.") await cruds_raid.update_team(team_id, team, db) @@ -690,8 +690,8 @@ async def read_document( detail="Participant owning the document not found.", ) - is_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) - if not is_admin: + is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) + if not is_raid_admin: # Self or teammate can read user_team = await cruds_raid.get_team_by_participant_id( user.id, @@ -1320,11 +1320,11 @@ async def update_volunteer( 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: + is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) + if user.id != user_id and not is_raid_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: + if existing.validated and not is_raid_admin: raise HTTPException( status_code=400, detail="Volunteer is validated; admin-only update", @@ -1361,8 +1361,8 @@ async def cancel_volunteer( ), 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: + is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) + if user.id != user_id and not is_raid_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) @@ -1380,11 +1380,11 @@ async def delete_volunteer( ), 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: + is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) + if user.id != user_id and not is_raid_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: + if existing.validated and not is_raid_admin: raise HTTPException( status_code=403, detail="Cannot remove a validated volunteer (admin-only)", From acaeaf0b7ce7cbad9ad7c86e4068d35e442944cf Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Mon, 24 Aug 2026 21:07:43 +0200 Subject: [PATCH 14/36] feat: gating security data read via selecting load restriction --- app/modules/raid/cruds_raid.py | 50 +++++++++++++++++++++++---- app/modules/raid/dependencies_raid.py | 14 ++++++-- app/modules/raid/endpoints_raid.py | 15 ++++---- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index aaf991f80b..d1a35c56c7 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -13,6 +13,21 @@ RaidRegistrationStatus, ) +PARTICIPANT_DATA_TO_SELECT = [ + models_raid.RaidParticipant.id_card, + models_raid.RaidParticipant.medical_certificate, + models_raid.RaidParticipant.student_card, + models_raid.RaidParticipant.raid_rules, + models_raid.RaidParticipant.parent_authorization, + models_raid.RaidParticipant.user, +] + +TEAM_DATA_TO_SELECT = [ + models_raid.RaidTeam.captain, + models_raid.RaidTeam.second, + *PARTICIPANT_DATA_TO_SELECT, +] + async def create_participant( participant: schemas_raid.RaidParticipantCreate, @@ -53,7 +68,9 @@ async def get_all_participants( stmt = ( select(models_raid.RaidParticipant) .where(models_raid.RaidParticipant.edition_id == edition_id) - .options(selectinload("*")) + .options( + *[selectinload(data) for data in PARTICIPANT_DATA_TO_SELECT], + ) ) if status is not None: stmt = stmt.where(models_raid.RaidParticipant.status == status) @@ -152,7 +169,9 @@ async def get_team_by_participant_id( models_raid.RaidTeam.second_id == user_id, ), ) - .options(selectinload("*")), + .options( + *[selectinload(data) for data in TEAM_DATA_TO_SELECT], + ), ) model = team.scalars().first() return schemas_raid.RaidTeam.model_validate(model) if model else None @@ -165,7 +184,9 @@ async def get_all_teams( teams = await db.execute( select(models_raid.RaidTeam) .where(models_raid.RaidTeam.edition_id == edition_id) - .options(selectinload("*")), + .options( + *[selectinload(data) for data in TEAM_DATA_TO_SELECT], + ), ) return [schemas_raid.RaidTeam.model_validate(t) for t in teams.scalars().all()] @@ -202,7 +223,9 @@ async def get_all_validated_teams( Captain.c.status == RaidRegistrationStatus.validated, Second.c.status == RaidRegistrationStatus.validated, ) - .options(selectinload("*")) + .options( + *[selectinload(data) for data in TEAM_DATA_TO_SELECT], + ) ) teams = await db.execute(stmt) return [schemas_raid.RaidTeam.model_validate(t) for t in teams.scalars().all()] @@ -215,7 +238,9 @@ async def get_team_by_id( team = await db.execute( select(models_raid.RaidTeam) .where(models_raid.RaidTeam.id == team_id) - .options(selectinload("*")), + .options( + *[selectinload(data) for data in TEAM_DATA_TO_SELECT], + ), ) model = team.scalars().first() return schemas_raid.RaidTeam.model_validate(model) if model else None @@ -515,7 +540,9 @@ async def get_user_by_document_id( models_raid.RaidParticipant.parent_authorization_id == document_id, ), ) - .options(selectinload("*")), + .options( + *[selectinload(data) for data in PARTICIPANT_DATA_TO_SELECT], + ), ) model = document.scalars().first() return schemas_raid.RaidParticipant.model_validate(model) if model else None @@ -598,14 +625,23 @@ async def get_participant_by_user_id( user_id: str, edition_id: UUID, db: AsyncSession, + include_security_file: bool = False, ) -> schemas_raid.RaidParticipant | None: + + data_to_select = PARTICIPANT_DATA_TO_SELECT.copy() + + if include_security_file: + data_to_select.append(models_raid.RaidParticipant.security_file) + participant = await db.execute( select(models_raid.RaidParticipant) .where( models_raid.RaidParticipant.user_id == user_id, models_raid.RaidParticipant.edition_id == edition_id, ) - .options(selectinload("*")), + .options( + *[selectinload(data) for data in data_to_select], + ), ) model = participant.scalars().first() return schemas_raid.RaidParticipant.model_validate(model) if model else None diff --git a/app/modules/raid/dependencies_raid.py b/app/modules/raid/dependencies_raid.py index f3db544126..7a94053011 100644 --- a/app/modules/raid/dependencies_raid.py +++ b/app/modules/raid/dependencies_raid.py @@ -28,8 +28,14 @@ async def get_participant_or_404( user_id: str, edition_id: UUID, db: AsyncSession = Depends(get_db), + include_security_file: bool = False, ) -> schemas_raid.RaidParticipant: - participant = await cruds_raid.get_participant_by_user_id(user_id, edition_id, db) + participant = await cruds_raid.get_participant_by_user_id( + user_id, + edition_id, + db, + include_security_file, + ) if participant is None: raise HTTPException(status_code=404, detail="Participant not found") return participant @@ -53,7 +59,11 @@ async def ensure_user_is_not_participant_in_edition( ) -> 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) + participant = await cruds_raid.get_participant_by_user_id( + user_id, + edition_id, + db, + ) if ( participant is not None and participant.status != RaidRegistrationStatus.cancelled diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index ad168582fe..6c1238c1cb 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -217,13 +217,7 @@ async def get_participant_by_id( detail="You can not get data of another user", ) - participant = await get_participant_or_404(user_id, edition.id, db) - - # If the user is not the owner, hide its security file from the response - if not is_owner: - participant.security_file = None - - return participant + return await get_participant_or_404(user_id, edition.id, db, not is_owner) @module.router.post( @@ -263,7 +257,7 @@ async def create_participant( is_minor=is_minor, ) await cruds_raid.create_participant(participant_create, db) - return await get_participant_or_404(user.id, edition.id, db) + return await get_participant_or_404(user.id, edition.id, db, True) @module.router.patch( @@ -281,6 +275,8 @@ async def update_participant( ): saved_participant = await get_participant_or_404(user_id, edition.id, db) + print(f"saved_participant: {saved_participant}") + is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) if user.id != user_id and not is_raid_admin: raise HTTPException(status_code=403, detail="You are not the participant.") @@ -402,7 +398,8 @@ async def validate_participant( ), edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): - participant = await get_participant_or_404(user_id, edition.id, db) + participant = await get_participant_or_404(user_id, edition.id, db, True) + await check_participant_validation_consistency(participant, edition.id, db) await cruds_raid.update_participant_status( user_id, From 96f1970fb2569ba3fc45f35f55b0516049813f44 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:13:25 +0200 Subject: [PATCH 15/36] fix: nullable consent_given_at in migration --- migrations/versions/66-raid-security-file-consent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/66-raid-security-file-consent.py b/migrations/versions/66-raid-security-file-consent.py index c66dd836b4..24954dd36c 100644 --- a/migrations/versions/66-raid-security-file-consent.py +++ b/migrations/versions/66-raid-security-file-consent.py @@ -29,7 +29,7 @@ def upgrade() -> None: ) op.add_column( "raid_security_file", - sa.Column("consent_given_at", TZDateTime(), nullable=False), + sa.Column("consent_given_at", TZDateTime(), nullable=True), ) From 4524507c3fbbe5610cf94dd2becbb2014cbc6e3a Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:18:22 +0200 Subject: [PATCH 16/36] fix: remove print --- app/modules/raid/endpoints_raid.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 6c1238c1cb..ac86e6c9f8 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -275,8 +275,6 @@ async def update_participant( ): saved_participant = await get_participant_or_404(user_id, edition.id, db) - print(f"saved_participant: {saved_participant}") - is_raid_admin = await has_user_permission(user, RaidPermissions.manage_raid, db) if user.id != user_id and not is_raid_admin: raise HTTPException(status_code=403, detail="You are not the participant.") From e7d5edeb156af2eec89bd93cf07583d7b04f69e4 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:18:41 +0200 Subject: [PATCH 17/36] fix: don't copy list before selecting relationship in cruds --- app/modules/raid/cruds_raid.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index d1a35c56c7..6c88c41b05 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -628,10 +628,12 @@ async def get_participant_by_user_id( include_security_file: bool = False, ) -> schemas_raid.RaidParticipant | None: - data_to_select = PARTICIPANT_DATA_TO_SELECT.copy() + additionnal_data_to_select = [] if include_security_file: - data_to_select.append(models_raid.RaidParticipant.security_file) + additionnal_data_to_select.append( + selectinload(models_raid.RaidParticipant.security_file), + ) participant = await db.execute( select(models_raid.RaidParticipant) @@ -640,7 +642,8 @@ async def get_participant_by_user_id( models_raid.RaidParticipant.edition_id == edition_id, ) .options( - *[selectinload(data) for data in data_to_select], + *[selectinload(data) for data in PARTICIPANT_DATA_TO_SELECT], + *additionnal_data_to_select, ), ) model = participant.scalars().first() From a37812e69081a097f180a7d7121aaff07f040224 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:27:19 +0200 Subject: [PATCH 18/36] fix: select relationship to prevent greenlet --- app/modules/raid/cruds_raid.py | 17 ++++++---- tests/modules/test_raid.py | 60 +++++++++++++++------------------- 2 files changed, 37 insertions(+), 40 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index 6c88c41b05..a775e87d9e 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -20,12 +20,15 @@ models_raid.RaidParticipant.raid_rules, models_raid.RaidParticipant.parent_authorization, models_raid.RaidParticipant.user, + models_raid.RaidParticipant.security_file, ] TEAM_DATA_TO_SELECT = [ - models_raid.RaidTeam.captain, - models_raid.RaidTeam.second, - *PARTICIPANT_DATA_TO_SELECT, + selectinload(models_raid.RaidTeam.captain).selectinload(attribute) + for attribute in PARTICIPANT_DATA_TO_SELECT +] + [ + selectinload(models_raid.RaidTeam.second).selectinload(attribute) + for attribute in PARTICIPANT_DATA_TO_SELECT ] @@ -170,7 +173,7 @@ async def get_team_by_participant_id( ), ) .options( - *[selectinload(data) for data in TEAM_DATA_TO_SELECT], + *TEAM_DATA_TO_SELECT, ), ) model = team.scalars().first() @@ -185,7 +188,7 @@ async def get_all_teams( select(models_raid.RaidTeam) .where(models_raid.RaidTeam.edition_id == edition_id) .options( - *[selectinload(data) for data in TEAM_DATA_TO_SELECT], + *TEAM_DATA_TO_SELECT, ), ) return [schemas_raid.RaidTeam.model_validate(t) for t in teams.scalars().all()] @@ -224,7 +227,7 @@ async def get_all_validated_teams( Second.c.status == RaidRegistrationStatus.validated, ) .options( - *[selectinload(data) for data in TEAM_DATA_TO_SELECT], + *TEAM_DATA_TO_SELECT, ) ) teams = await db.execute(stmt) @@ -239,7 +242,7 @@ async def get_team_by_id( select(models_raid.RaidTeam) .where(models_raid.RaidTeam.id == team_id) .options( - *[selectinload(data) for data in TEAM_DATA_TO_SELECT], + *TEAM_DATA_TO_SELECT, ), ) model = team.scalars().first() diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index 1a01af223e..649fec47c1 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -7,7 +7,6 @@ the participant/volunteer payloads stay small and mirror the real API shape. """ -import asyncio import datetime import uuid @@ -442,7 +441,9 @@ def test_admin_validate_fails_before_prerequisites(client: TestClient) -> None: assert r.status_code == 400 -async def _prepare_full_validation_state() -> None: +async def test_admin_validate_full_happy_path( + client: TestClient, +) -> None: """Promote captain + second to every prerequisite (except difficulty/meeting).""" async with get_TestingSessionLocal()() as db: docs = {} @@ -506,20 +507,17 @@ async def _prepare_full_validation_state() -> None: ) 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 r.status_code == 204, r.json() + assert r.status_code == 204 r = client.get( f"/raid/participants/{user_captain.id}", headers={"Authorization": f"Bearer {token_admin}"}, ) + assert r.status_code == 200 assert r.json()["status"] == "validated" @@ -776,22 +774,20 @@ def test_update_volunteer_self(client: TestClient) -> None: assert r.status_code == 204 -def test_validate_volunteer_fails_with_car_but_no_seats( +async 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 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() r = client.patch( f"/raid/volunteers/{user_volunteer.id}/validate", @@ -801,20 +797,18 @@ async def _break_car(): assert "car_seats" in r.json()["detail"] -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() +async def test_validate_volunteer_success(client: TestClient) -> None: - asyncio.get_event_loop().run_until_complete(_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() r = client.patch( f"/raid/volunteers/{user_volunteer.id}/validate", From 4f8a265c43145008940210974446d07093181bb6 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:24:27 +0200 Subject: [PATCH 19/36] feat: use restricted participant without securityfile when possible --- app/modules/raid/cruds_raid.py | 97 ++++++++++++++++---- app/modules/raid/dependencies_raid.py | 22 ++++- app/modules/raid/endpoints_raid.py | 62 +++++++++---- app/modules/raid/schemas_raid.py | 40 ++++++-- app/modules/raid/utils/utils_raid.py | 14 +-- app/modules/raid/utils/validation_checker.py | 37 +++++--- tests/modules/raid/test_pdf_generation.py | 4 +- tests/modules/raid/test_utils_raid.py | 4 +- tests/modules/test_raid.py | 12 ++- 9 files changed, 217 insertions(+), 75 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index a775e87d9e..a10d1d8aa6 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -20,7 +20,6 @@ models_raid.RaidParticipant.raid_rules, models_raid.RaidParticipant.parent_authorization, models_raid.RaidParticipant.user, - models_raid.RaidParticipant.security_file, ] TEAM_DATA_TO_SELECT = [ @@ -67,7 +66,7 @@ async def get_all_participants( edition_id: UUID, db: AsyncSession, status: RaidRegistrationStatus | None = None, -) -> list[schemas_raid.RaidParticipant]: +) -> list[schemas_raid.RaidParticipantRestricted]: stmt = ( select(models_raid.RaidParticipant) .where(models_raid.RaidParticipant.edition_id == edition_id) @@ -81,13 +80,11 @@ async def get_all_participants( # Remove security_file from the participants list to avoid including it in the response. found_participants = participants.scalars().all() - cleaned_participants = [] - for p in found_participants: - participant = schemas_raid.RaidParticipant.model_validate(p) - participant.security_file = None - cleaned_participants.append(participant) - return cleaned_participants + return [ + schemas_raid.RaidParticipantRestricted.model_validate(participant) + for participant in found_participants + ] async def update_participant( @@ -194,6 +191,29 @@ async def get_all_teams( return [schemas_raid.RaidTeam.model_validate(t) for t in teams.scalars().all()] +async def get_all_teams_including_security_files( + edition_id: UUID, + db: AsyncSession, +) -> list[schemas_raid.RaidTeamIncludingSecurityFile]: + teams = await db.execute( + select(models_raid.RaidTeam) + .where(models_raid.RaidTeam.edition_id == edition_id) + .options( + *TEAM_DATA_TO_SELECT, + selectinload(models_raid.RaidTeam.captain).selectinload( + models_raid.RaidParticipant.security_file, + ), + selectinload(models_raid.RaidTeam.second).selectinload( + models_raid.RaidParticipant.security_file, + ), + ), + ) + return [ + schemas_raid.RaidTeamIncludingSecurityFile.model_validate(t) + for t in teams.scalars().all() + ] + + async def get_all_validated_teams( edition_id: UUID, db: AsyncSession, @@ -249,6 +269,31 @@ async def get_team_by_id( return schemas_raid.RaidTeam.model_validate(model) if model else None +async def get_team_including_security_file_by_id( + team_id: str, + db: AsyncSession, +) -> schemas_raid.RaidTeamIncludingSecurityFile | None: + team = await db.execute( + select(models_raid.RaidTeam) + .where(models_raid.RaidTeam.id == team_id) + .options( + *TEAM_DATA_TO_SELECT, + selectinload(models_raid.RaidTeam.captain).selectinload( + models_raid.RaidParticipant.security_file, + ), + selectinload(models_raid.RaidTeam.second).selectinload( + models_raid.RaidParticipant.security_file, + ), + ), + ) + model = team.scalars().first() + return ( + schemas_raid.RaidTeamIncludingSecurityFile.model_validate(model) + if model + else None + ) + + async def create_team( team: schemas_raid.RaidTeamCreate, db: AsyncSession, @@ -531,7 +576,7 @@ async def get_document_by_id( async def get_user_by_document_id( document_id: str, db: AsyncSession, -) -> schemas_raid.RaidParticipant | None: +) -> schemas_raid.RaidParticipantRestricted | None: document = await db.execute( select(models_raid.RaidParticipant) .where( @@ -548,7 +593,9 @@ async def get_user_by_document_id( ), ) model = document.scalars().first() - return schemas_raid.RaidParticipant.model_validate(model) if model else None + return ( + schemas_raid.RaidParticipantRestricted.model_validate(model) if model else None + ) async def update_document( @@ -628,15 +675,29 @@ async def get_participant_by_user_id( user_id: str, edition_id: UUID, db: AsyncSession, - include_security_file: bool = False, -) -> schemas_raid.RaidParticipant | None: - - additionnal_data_to_select = [] +) -> schemas_raid.RaidParticipantRestricted | None: - if include_security_file: - additionnal_data_to_select.append( - selectinload(models_raid.RaidParticipant.security_file), + participant = await db.execute( + select(models_raid.RaidParticipant) + .where( + models_raid.RaidParticipant.user_id == user_id, + models_raid.RaidParticipant.edition_id == edition_id, ) + .options( + *[selectinload(data) for data in PARTICIPANT_DATA_TO_SELECT], + ), + ) + model = participant.scalars().first() + return ( + schemas_raid.RaidParticipantRestricted.model_validate(model) if model else None + ) + + +async def get_participant_complete_by_user_id( + user_id: str, + edition_id: UUID, + db: AsyncSession, +) -> schemas_raid.RaidParticipant | None: participant = await db.execute( select(models_raid.RaidParticipant) @@ -646,7 +707,7 @@ async def get_participant_by_user_id( ) .options( *[selectinload(data) for data in PARTICIPANT_DATA_TO_SELECT], - *additionnal_data_to_select, + selectinload(models_raid.RaidParticipant.security_file), ), ) model = participant.scalars().first() diff --git a/app/modules/raid/dependencies_raid.py b/app/modules/raid/dependencies_raid.py index 7a94053011..93007b11cb 100644 --- a/app/modules/raid/dependencies_raid.py +++ b/app/modules/raid/dependencies_raid.py @@ -28,13 +28,29 @@ async def get_participant_or_404( user_id: str, edition_id: UUID, db: AsyncSession = Depends(get_db), - include_security_file: bool = False, -) -> schemas_raid.RaidParticipant: +) -> schemas_raid.RaidParticipantRestricted: participant = await cruds_raid.get_participant_by_user_id( user_id, edition_id, db, - include_security_file, + ) + if participant is None: + raise HTTPException(status_code=404, detail="Participant not found") + return participant + + +async def get_participant_complete_or_404( + user_id: str, + edition_id: UUID, + db: AsyncSession = Depends(get_db), +) -> schemas_raid.RaidParticipant: + """ + Include the participant's security file + """ + participant = await cruds_raid.get_participant_complete_by_user_id( + user_id, + edition_id, + db, ) if participant is None: raise HTTPException(status_code=404, detail="Participant not found") diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index ac86e6c9f8..e21fd84535 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -22,6 +22,7 @@ ensure_user_is_not_participant_in_edition, ensure_user_is_not_volunteer_in_edition, get_current_raid_edition, + get_participant_complete_or_404, get_participant_or_404, get_volunteer_or_404, ) @@ -192,32 +193,36 @@ async def delete_edition( @module.router.get( - "/raid/participants/{user_id}", + "/raid/participants/me", response_model=schemas_raid.RaidParticipant, status_code=200, ) -async def get_participant_by_id( - user_id: str, +async def get_my_participant( 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_owner = user.id == user_id - is_raid_admin = await has_user_permission( - user, - RaidPermissions.manage_raid, - db, - ) - if not is_owner and not is_raid_admin: - raise HTTPException( - status_code=403, - detail="You can not get data of another user", - ) + return await get_participant_complete_or_404(user.id, edition.id, db) - return await get_participant_or_404(user_id, edition.id, db, not is_owner) + +@module.router.get( + "/raid/participants/{user_id}", + response_model=schemas_raid.RaidParticipantRestricted, + status_code=200, +) +async def get_participant_by_id( + 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), +): + + return await get_participant_or_404(user_id, edition.id, db) @module.router.post( @@ -257,7 +262,7 @@ async def create_participant( is_minor=is_minor, ) await cruds_raid.create_participant(participant_create, db) - return await get_participant_or_404(user.id, edition.id, db, True) + return await get_participant_complete_or_404(user.id, edition.id, db) @module.router.patch( @@ -396,7 +401,11 @@ async def validate_participant( ), edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): - participant = await get_participant_or_404(user_id, edition.id, db, True) + participant = await get_participant_complete_or_404( + user_id, + edition.id, + db, + ) await check_participant_validation_consistency(participant, edition.id, db) await cruds_raid.update_participant_status( @@ -515,7 +524,7 @@ async def get_all_teams( @module.router.get( "/raid/teams/{team_id}", - response_model=schemas_raid.RaidTeam, + response_model=schemas_raid.RaidTeamComplete, status_code=200, ) async def get_team_by_id( @@ -525,10 +534,23 @@ async def get_team_by_id( is_user_allowed_to([RaidPermissions.manage_raid]), ), ): - team = await cruds_raid.get_team_by_id(team_id, db) + team = await cruds_raid.get_team_including_security_file_by_id(team_id, db) if not team: raise HTTPException(status_code=404, detail="Team not found.") - return team + return schemas_raid.RaidTeamComplete( + name=team.name, + id=team.id, + edition_id=team.edition_id, + number=team.number, + captain_id=team.captain_id, + second_id=team.second_id, + difficulty=team.difficulty, + meeting_place=team.meeting_place, + file_id=team.file_id, + captain=team.captain, + second=team.second, + validation_progress=team.validation_progress, + ) @module.router.patch( diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 2e70d830bc..50f542949e 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -122,7 +122,11 @@ class RaidParticipantPreview(RaidParticipantBase): model_config = ConfigDict(from_attributes=True) -class RaidParticipant(RaidParticipantPreview): +class RaidParticipantRestricted(RaidParticipantPreview): + """ + The security file is not included in this schema + """ + address: str | None = None other_school: str | None = None company: str | None = None @@ -132,7 +136,6 @@ class RaidParticipant(RaidParticipantPreview): 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 @@ -142,11 +145,6 @@ class RaidParticipant(RaidParticipantPreview): attestation_on_honour: bool is_minor: bool - @computed_field # type: ignore[prop-decorator] - @property - def validation_progress(self) -> float: - return compute_participant_progress(self) - @computed_field # type: ignore[prop-decorator] @property def number_of_document(self) -> int: @@ -158,6 +156,20 @@ def number_of_validated_document(self) -> int: return count_accepted_documents(self) +class RaidParticipantRestrictedComplete(RaidParticipantPreview): + # Use compute_participant_progress to compute the validation progress + validation_progress: float + + +class RaidParticipant(RaidParticipantRestricted): + security_file: SecurityFile | None = None + + @computed_field # type: ignore[prop-decorator] + @property + def validation_progress(self) -> float: + return compute_participant_progress(self) + + class RaidParticipantUpdate(BaseModel): address: str | None = None bike_size: Size | None = None @@ -240,15 +252,25 @@ class RaidTeam(RaidTeamBase): 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 file_id: str | None = None model_config = ConfigDict(from_attributes=True) + captain: RaidParticipantRestricted + second: RaidParticipantRestricted | None = None + + +class RaidTeamComplete(RaidTeam): + validation_progress: float + + +class RaidTeamIncludingSecurityFile(RaidTeam): + captain: RaidParticipant + second: RaidParticipant | None = None + @computed_field # type: ignore[prop-decorator] @property def validation_progress(self) -> float: diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index 5fb54bd505..384e486b57 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -123,7 +123,9 @@ async def set_team_number( await cruds_raid.update_team(team.id, updated_team, db) -def _participant_pdf_context(participant: schemas_raid.RaidParticipant) -> dict: +def _participant_pdf_context( + participant: schemas_raid.RaidParticipantRestricted, +) -> dict: """Build a template context with identity fields pulled from CoreUser.""" ctx = participant.model_dump() if participant.user is not None: @@ -168,7 +170,7 @@ async def generate_security_file_pdf( async def generate_recap_file_pdf( - team: schemas_raid.RaidTeam, + team: schemas_raid.RaidTeamIncludingSecurityFile, ): context = { "team_name": team.name, @@ -196,7 +198,7 @@ async def get_all_security_files_zip( information: coredata_raid.RaidInformation, edition_id: UUID, ) -> str: - teams = await cruds_raid.get_all_teams(edition_id, db) + teams = await cruds_raid.get_all_teams_including_security_files(edition_id, db) hyperion_error_logger.info( f"RAID: Generating ZIP for {len(teams)} security files", ) @@ -234,7 +236,7 @@ async def get_all_team_files_zip( information: coredata_raid.RaidInformation, edition_id: UUID, ) -> str: - teams = await cruds_raid.get_all_teams(edition_id, db) + teams = await cruds_raid.get_all_teams_including_security_files(edition_id, db) hyperion_error_logger.info( f"RAID: Generating ZIP for {len(teams)} team recap files", ) @@ -268,7 +270,7 @@ async def get_participant( user_id: str, edition_id: UUID, db: AsyncSession, -) -> schemas_raid.RaidParticipant: +) -> schemas_raid.RaidParticipantRestricted: 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.") @@ -276,7 +278,7 @@ async def get_participant( def calculate_raid_payment( - participant: schemas_raid.RaidParticipant, + participant: schemas_raid.RaidParticipantRestricted, 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 8741f79460..e0944d786d 100644 --- a/app/modules/raid/utils/validation_checker.py +++ b/app/modules/raid/utils/validation_checker.py @@ -46,7 +46,7 @@ async def check_participant_validation_consistency( def _check_edition_scope( - participant: schemas_raid.RaidParticipant, + participant: schemas_raid.RaidParticipantRestricted, edition_id, ) -> None: if participant.edition_id != edition_id: @@ -56,7 +56,9 @@ def _check_edition_scope( ) -def _check_attestation_signed(participant: schemas_raid.RaidParticipant) -> None: +def _check_attestation_signed( + participant: schemas_raid.RaidParticipantRestricted, +) -> None: if not participant.attestation_on_honour: raise HTTPException( status_code=400, @@ -64,7 +66,7 @@ def _check_attestation_signed(participant: schemas_raid.RaidParticipant) -> None ) -def _check_payment_done(participant: schemas_raid.RaidParticipant) -> None: +def _check_payment_done(participant: schemas_raid.RaidParticipantRestricted) -> None: if not participant.payment: raise HTTPException( status_code=400, @@ -81,7 +83,9 @@ def _check_payment_done(participant: schemas_raid.RaidParticipant) -> None: ) -def _check_security_file_complete(participant: schemas_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( @@ -99,7 +103,9 @@ def _check_security_file_complete(participant: schemas_raid.RaidParticipant) -> ) -def _check_all_documents_accepted(participant: schemas_raid.RaidParticipant) -> None: +def _check_all_documents_accepted( + participant: schemas_raid.RaidParticipantRestricted, +) -> 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") @@ -129,7 +135,7 @@ def _check_document_accepted( async def _check_team_complete( - participant: schemas_raid.RaidParticipant, + participant: schemas_raid.RaidParticipantRestricted, db: AsyncSession, ) -> None: team = await cruds_raid.get_team_by_participant_id( @@ -259,7 +265,9 @@ class _DocumentRule: ) -def _context(participant: schemas_raid.RaidParticipant) -> _ParticipantContext: +def _context( + participant: schemas_raid.RaidParticipantRestricted, +) -> _ParticipantContext: return _ParticipantContext( situation=participant.situation, is_minor=participant.is_minor, @@ -270,7 +278,10 @@ 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: +def _score( + participant: schemas_raid.RaidParticipant, + rule: _DocumentRule, +) -> float: doc = getattr(participant, rule.attr) if doc is None: return 0.0 @@ -301,7 +312,7 @@ def compute_participant_progress( return ((filled_profile + scored_docs) / total) * 100 -def compute_team_progress(team: schemas_raid.RaidTeam) -> float: +def compute_team_progress(team: schemas_raid.RaidTeamIncludingSecurityFile) -> float: """Combine the two participants' progress with the team-level metadata.""" team_filled = int(team.difficulty is not None) + int(team.meeting_place is not None) team_share = (team_filled / 2) * 10 @@ -310,7 +321,9 @@ def compute_team_progress(team: schemas_raid.RaidTeam) -> float: return team_share + (captain + second) * 0.45 -def count_total_required_documents(participant: schemas_raid.RaidParticipant) -> int: +def count_total_required_documents( + participant: schemas_raid.RaidParticipantRestricted, +) -> int: """Number of upload slots required for this participant's profile.""" return sum( 1 @@ -319,7 +332,9 @@ def count_total_required_documents(participant: schemas_raid.RaidParticipant) -> ) -def count_accepted_documents(participant: schemas_raid.RaidParticipant) -> int: +def count_accepted_documents( + participant: schemas_raid.RaidParticipantRestricted, +) -> int: """Number of required uploads that are currently in the `accepted` state.""" return sum( 1 diff --git a/tests/modules/raid/test_pdf_generation.py b/tests/modules/raid/test_pdf_generation.py index 6d7e58ebc5..b557f73f16 100644 --- a/tests/modules/raid/test_pdf_generation.py +++ b/tests/modules/raid/test_pdf_generation.py @@ -142,7 +142,7 @@ async def test_get_all_team_files_zip_uses_team_id_for_pdf(self): with ( patch( - "app.modules.raid.utils.utils_raid.cruds_raid.get_all_teams", + "app.modules.raid.utils.utils_raid.cruds_raid.get_all_teams_including_security_files", new=AsyncMock(return_value=[team]), ), patch( @@ -172,7 +172,7 @@ async def test_get_all_security_files_zip_uses_user_id_for_pdf(self): with ( patch( - "app.modules.raid.utils.utils_raid.cruds_raid.get_all_teams", + "app.modules.raid.utils.utils_raid.cruds_raid.get_all_teams_including_security_files", new=AsyncMock(return_value=[team]), ), patch( diff --git a/tests/modules/raid/test_utils_raid.py b/tests/modules/raid/test_utils_raid.py index bc282b930b..a4c902443c 100644 --- a/tests/modules/raid/test_utils_raid.py +++ b/tests/modules/raid/test_utils_raid.py @@ -328,7 +328,7 @@ async def test_get_all_security_files_zip_no_teams(mocker: MockerFixture) -> Non edition_id = uuid4() mocker.patch( - "app.modules.raid.cruds_raid.get_all_teams", + "app.modules.raid.cruds_raid.get_all_teams_including_security_files", new=AsyncMock(return_value=[]), ) @@ -347,7 +347,7 @@ async def test_get_all_team_files_zip_no_teams(mocker: MockerFixture) -> None: edition_id = uuid4() mocker.patch( - "app.modules.raid.cruds_raid.get_all_teams", + "app.modules.raid.cruds_raid.get_all_teams_including_security_files", new=AsyncMock(return_value=[]), ) diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index 649fec47c1..09e2799414 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -297,7 +297,7 @@ def test_delete_edition_with_participants_rejected(client: TestClient) -> None: def test_get_participant_self(client: TestClient) -> None: r = client.get( - f"/raid/participants/{user_captain.id}", + "/raid/participants/me", headers={"Authorization": f"Bearer {token_captain}"}, ) assert r.status_code == 200 @@ -557,6 +557,7 @@ def test_admin_reopen_to_draft(client: TestClient) -> None: f"/raid/participants/{user_captain.id}", headers={"Authorization": f"Bearer {token_admin}"}, ) + assert r2.status_code == 200 assert r2.json()["status"] == "draft" @@ -567,9 +568,10 @@ def test_cancel_by_self(client: TestClient) -> None: ) assert r.status_code == 204 r2 = client.get( - f"/raid/participants/{user_solo.id}", + "/raid/participants/me", headers={"Authorization": f"Bearer {token_solo}"}, ) + assert r2.status_code == 200 assert r2.json()["status"] == "cancelled" @@ -606,10 +608,12 @@ def test_get_team_by_participant(client: TestClient) -> None: def test_update_team_by_captain(client: TestClient) -> None: - team = client.get( + r = client.get( f"/raid/participants/{user_captain.id}/team", headers={"Authorization": f"Bearer {token_captain}"}, - ).json() + ) + assert r.status_code == 200 + team = r.json() r = client.patch( f"/raid/teams/{team['id']}", json={"name": "MainTeam-Renamed"}, From 91b3a768ee24ead8764dbc2d73bea63f34fd8b43 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:08:42 +0200 Subject: [PATCH 20/36] Return progress for GET admin participant --- app/modules/raid/endpoints_raid.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index e21fd84535..865ffbdca8 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -210,7 +210,7 @@ async def get_my_participant( @module.router.get( "/raid/participants/{user_id}", - response_model=schemas_raid.RaidParticipantRestricted, + response_model=schemas_raid.RaidParticipantRestrictedComplete, status_code=200, ) async def get_participant_by_id( @@ -222,7 +222,20 @@ async def get_participant_by_id( edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): - return await get_participant_or_404(user_id, edition.id, db) + participant = await get_participant_complete_or_404(user_id, edition.id, db) + + return schemas_raid.RaidParticipantRestrictedComplete( + user_id=participant.user_id, + edition_id=participant.edition_id, + status=participant.status, + bike_size=participant.bike_size, + t_shirt_size=participant.t_shirt_size, + situation=participant.situation, + payment=participant.payment, + t_shirt_payment=participant.t_shirt_payment, + user=participant.user, + validation_progress=participant.validation_progress, + ) @module.router.post( From ea66be791c2e1c97c85d7a98c0eec2317a7825ab Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Thu, 27 Aug 2026 16:31:37 +0200 Subject: [PATCH 21/36] feat: adding own team complete retrieval for participant --- app/modules/raid/endpoints_raid.py | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 865ffbdca8..d042859ca3 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -495,6 +495,49 @@ async def create_team( return await cruds_raid.get_team_by_id(team_id=team_id, db=db) +@module.router.get( + "/raid/participants/me/team", + response_model=schemas_raid.RaidTeamComplete, + status_code=200, +) +async def get_my_team( + 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), +): + 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.") + + team = await cruds_raid.get_team_including_security_file_by_id( + participant_team.id, + db, + ) + if not team: + raise HTTPException(status_code=404, detail="Team not found.") + + return schemas_raid.RaidTeamComplete( + name=team.name, + id=team.id, + edition_id=team.edition_id, + number=team.number, + captain_id=team.captain_id, + second_id=team.second_id, + difficulty=team.difficulty, + meeting_place=team.meeting_place, + file_id=team.file_id, + captain=team.captain, + second=team.second, + validation_progress=team.validation_progress, + ) + + @module.router.get( "/raid/participants/{user_id}/team", response_model=schemas_raid.RaidTeam, From 38c3e3aed969cc822683365853423f9204de9013 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:00:20 +0200 Subject: [PATCH 22/36] Select only the team once when getting complete team --- app/modules/raid/cruds_raid.py | 32 +++++++++++++++++++++++ app/modules/raid/endpoints_raid.py | 41 +++++++++++++----------------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index a10d1d8aa6..a4ac48c3ec 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -177,6 +177,38 @@ async def get_team_by_participant_id( return schemas_raid.RaidTeam.model_validate(model) if model else None +async def get_team_including_security_files_by_participant_id( + user_id: str, + edition_id: UUID, + db: AsyncSession, +) -> schemas_raid.RaidTeamIncludingSecurityFile | None: + team = await db.execute( + select(models_raid.RaidTeam) + .where( + models_raid.RaidTeam.edition_id == edition_id, + or_( + models_raid.RaidTeam.captain_id == user_id, + models_raid.RaidTeam.second_id == user_id, + ), + ) + .options( + *TEAM_DATA_TO_SELECT, + selectinload(models_raid.RaidTeam.captain).selectinload( + models_raid.RaidParticipant.security_file, + ), + selectinload(models_raid.RaidTeam.second).selectinload( + models_raid.RaidParticipant.security_file, + ), + ), + ) + model = team.scalars().first() + return ( + schemas_raid.RaidTeamIncludingSecurityFile.model_validate(model) + if model + else None + ) + + async def get_all_teams( edition_id: UUID, db: AsyncSession, diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index d042859ca3..7b97a41318 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -507,34 +507,29 @@ async def get_my_team( ), edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): - participant_team = await cruds_raid.get_team_by_participant_id( - user.id, - edition.id, - db, + participant_team = ( + await cruds_raid.get_team_including_security_files_by_participant_id( + user.id, + edition.id, + db, + ) ) if not participant_team: raise HTTPException(status_code=404, detail="You do not have a team.") - team = await cruds_raid.get_team_including_security_file_by_id( - participant_team.id, - db, - ) - if not team: - raise HTTPException(status_code=404, detail="Team not found.") - return schemas_raid.RaidTeamComplete( - name=team.name, - id=team.id, - edition_id=team.edition_id, - number=team.number, - captain_id=team.captain_id, - second_id=team.second_id, - difficulty=team.difficulty, - meeting_place=team.meeting_place, - file_id=team.file_id, - captain=team.captain, - second=team.second, - validation_progress=team.validation_progress, + name=participant_team.name, + id=participant_team.id, + edition_id=participant_team.edition_id, + number=participant_team.number, + captain_id=participant_team.captain_id, + second_id=participant_team.second_id, + difficulty=participant_team.difficulty, + meeting_place=participant_team.meeting_place, + file_id=participant_team.file_id, + captain=participant_team.captain, + second=participant_team.second, + validation_progress=participant_team.validation_progress, ) From 72d30379c88ceeb6cc1404e3b6fe628f0ba6e5ed Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:06:26 +0200 Subject: [PATCH 23/36] Add test --- tests/modules/test_raid.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index 09e2799414..c7ebc3d28b 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -622,6 +622,15 @@ def test_update_team_by_captain(client: TestClient) -> None: assert r.status_code == 204 +def test_update_my_team_as_captain(client: TestClient) -> None: + r = client.get( + "/raid/participants/me/team", + headers={"Authorization": f"Bearer {token_captain}"}, + ) + assert r.status_code == 200 + assert r.json()["captain"]["user_id"] == user_captain.id + + # --------------------------------------------------------------------------- # Documents # --------------------------------------------------------------------------- From f81c3a954aa23a60c0be1a93dbff358b2cdb54a1 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:57:32 +0200 Subject: [PATCH 24/36] fix: inherit RaidParticipantRestrictedComplete from RaidParticipantRestricted --- app/modules/raid/endpoints_raid.py | 2 ++ app/modules/raid/schemas_raid.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 7b97a41318..66f48f37d8 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -235,6 +235,8 @@ async def get_participant_by_id( t_shirt_payment=participant.t_shirt_payment, user=participant.user, validation_progress=participant.validation_progress, + attestation_on_honour=participant.attestation_on_honour, + is_minor=participant.is_minor, ) diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 50f542949e..7f53d62ec4 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -156,7 +156,7 @@ def number_of_validated_document(self) -> int: return count_accepted_documents(self) -class RaidParticipantRestrictedComplete(RaidParticipantPreview): +class RaidParticipantRestrictedComplete(RaidParticipantRestricted): # Use compute_participant_progress to compute the validation progress validation_progress: float From d1f37a782f9c94ef741365db5bca2a6bb00f6200 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:57:43 +0200 Subject: [PATCH 25/36] fix: include RaidParticipantRestrictedComplete in RaidTeamComplete --- app/modules/raid/schemas_raid.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 7f53d62ec4..fcf5b68cc0 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -266,6 +266,9 @@ class RaidTeam(RaidTeamBase): class RaidTeamComplete(RaidTeam): validation_progress: float + captain: RaidParticipantRestrictedComplete + second: RaidParticipantRestrictedComplete | None = None + class RaidTeamIncludingSecurityFile(RaidTeam): captain: RaidParticipant From c8760cdd93adf14b07a8eb1071afd6c33d432d07 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 4 Sep 2026 16:52:06 +0200 Subject: [PATCH 26/36] feat: adding raid volunteer payment --- app/modules/raid/coredata_raid.py | 1 + app/modules/raid/cruds_raid.py | 62 ++++++++ app/modules/raid/endpoints_raid.py | 101 ++++++++++++- app/modules/raid/models_raid.py | 21 +++ app/modules/raid/schemas_raid.py | 14 ++ app/modules/raid/utils/utils_raid.py | 135 +++++++++++++----- .../versions/67-raid-payment-for-volunteer.py | 84 +++++++++++ .../modules/raid/test_utils_raid_extended.py | 3 + 8 files changed, 384 insertions(+), 37 deletions(-) create mode 100644 migrations/versions/67-raid-payment-for-volunteer.py diff --git a/app/modules/raid/coredata_raid.py b/app/modules/raid/coredata_raid.py index b7cc134546..094c005780 100644 --- a/app/modules/raid/coredata_raid.py +++ b/app/modules/raid/coredata_raid.py @@ -24,3 +24,4 @@ class RaidPrice(core_data.BaseCoreData): partner_price: int | None = None external_price: int | None = None t_shirt_price: int | None = None + volunteer_price: int | None = None diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index a4ac48c3ec..5f30cfcc99 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -687,6 +687,38 @@ async def confirm_t_shirt_payment( await db.flush() +async def confirm_volunteer_payment( + user_id: str, + edition_id: UUID, + 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(payment=True), + ) + await db.flush() + + +async def confirm_volunteer_t_shirt_payment( + user_id: str, + edition_id: UUID, + 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(t_shirt_payment=True), + ) + await db.flush() + + async def validate_attestation_on_honour( user_id: str, edition_id: UUID, @@ -901,6 +933,34 @@ async def get_participant_checkout_by_checkout_id( return schemas_raid.RaidParticipantCheckout.model_validate(model) if model else None +async def create_volunteer_checkout( + checkout: schemas_raid.RaidVolunteerCheckout, + db: AsyncSession, +) -> None: + db.add( + models_raid.RaidVolunteerCheckout( + id=str(uuid.uuid4()), + volunteer_user_id=checkout.volunteer_user_id, + edition_id=checkout.edition_id, + checkout_id=checkout.checkout_id, + ), + ) + await db.flush() + + +async def get_volunteer_checkout_by_checkout_id( + checkout_id: str, + db: AsyncSession, +) -> schemas_raid.RaidVolunteerCheckout | None: + checkout = await db.execute( + select(models_raid.RaidVolunteerCheckout).where( + models_raid.RaidVolunteerCheckout.checkout_id == checkout_id, + ), + ) + model = checkout.scalars().first() + return schemas_raid.RaidVolunteerCheckout.model_validate(model) if model else None + + # --- Edition CRUDs ------------------------------------------------------ @@ -1050,6 +1110,8 @@ async def create_volunteer( is_special_driver=volunteer.is_special_driver, is_utility_vehicle_driver=volunteer.is_utility_vehicle_driver, is_parcours_helper=volunteer.is_parcours_helper, + payment=volunteer.payment, + t_shirt_payment=volunteer.t_shirt_payment, ), ) await db.flush() diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 66f48f37d8..f12971d099 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -35,6 +35,7 @@ ) from app.modules.raid.utils.utils_raid import ( calculate_raid_payment, + calculate_volunteer_payment, get_all_security_files_zip, get_all_team_files_zip, validate_payment, @@ -529,8 +530,14 @@ async def get_my_team( difficulty=participant_team.difficulty, meeting_place=participant_team.meeting_place, file_id=participant_team.file_id, - captain=participant_team.captain, - second=participant_team.second, + captain=schemas_raid.RaidParticipantRestrictedComplete( + **participant_team.captain.model_dump(), + ), + second=schemas_raid.RaidParticipantRestrictedComplete( + **participant_team.second.model_dump(), + ) + if participant_team.second + else None, validation_progress=participant_team.validation_progress, ) @@ -600,8 +607,14 @@ async def get_team_by_id( difficulty=team.difficulty, meeting_place=team.meeting_place, file_id=team.file_id, - captain=team.captain, - second=team.second, + captain=schemas_raid.RaidParticipantRestrictedComplete( + **team.captain.model_dump(), + ), + second=schemas_raid.RaidParticipantRestrictedComplete( + **team.second.model_dump(), + ) + if team.second + else None, validation_progress=team.validation_progress, ) @@ -920,6 +933,43 @@ async def confirm_t_shirt_payment( await cruds_raid.confirm_t_shirt_payment(user_id, edition.id, db) +@module.router.post( + "/raid/volunteer/{user_id}/payment", + status_code=204, +) +async def confirm_volunteer_payment( + 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), +): + await cruds_raid.confirm_volunteer_payment(user_id, edition.id, db) + + +@module.router.post( + "/raid/volunteer/{user_id}/t_shirt_payment", + status_code=204, +) +async def confirm_volunteer_t_shirt_payment( + 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 cruds_raid.get_volunteer_by_user_id(user_id, edition.id, db) + if ( + not volunteer + or not volunteer.t_shirt_size + or volunteer.t_shirt_size == Size.None_ + ): + raise HTTPException(status_code=400, detail="T shirt size not set.") + await cruds_raid.confirm_volunteer_t_shirt_payment(user_id, edition.id, db) + + @module.router.post( "/raid/participant/{user_id}/honour", status_code=204, @@ -1218,6 +1268,49 @@ async def get_payment_url( return schemas_raid.PaymentUrl(url=checkout.payment_url) +@module.router.get( + "/raid/volunteers/pay", + response_model=schemas_raid.PaymentUrl, + status_code=201, +) +async def get_volunteer_payment_url( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + 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), +): + raid_prices = await get_core_data(coredata_raid.RaidPrice, db) + if not raid_prices.volunteer_price or not raid_prices.t_shirt_price: + raise HTTPException(status_code=404, detail="Volunteer prices not set.") + + volunteer = await cruds_raid.get_volunteer_by_user_id(user.id, edition.id, db) + if not volunteer: + raise HTTPException(status_code=403, detail="You are not a volunteer.") + price, checkout_name = calculate_volunteer_payment(volunteer, raid_prices) + + 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, + checkout_amount=price, + checkout_name=checkout_name, + payer_user=schemas_users.CoreUser(**user_dict), + db=db, + ) + hyperion_error_logger.info(f"RAID Volunteer: Logging Checkout id {checkout.id}") + await cruds_raid.create_volunteer_checkout( + schemas_raid.RaidVolunteerCheckout( + volunteer_user_id=user.id, + edition_id=edition.id, + checkout_id=str(checkout.id), + ), + db=db, + ) + return schemas_raid.PaymentUrl(url=checkout.payment_url) + + # --------------------------------------------------------------------------- # Bulk downloads # --------------------------------------------------------------------------- diff --git a/app/modules/raid/models_raid.py b/app/modules/raid/models_raid.py index 465bf76b0f..237c143fff 100644 --- a/app/modules/raid/models_raid.py +++ b/app/modules/raid/models_raid.py @@ -253,6 +253,25 @@ class RaidParticipantCheckout(Base): ) +class RaidVolunteerCheckout(Base): + __tablename__ = "raid_volunteer_checkout" + id: Mapped[str] = mapped_column( + primary_key=True, + index=True, + ) + volunteer_user_id: Mapped[str] + edition_id: Mapped[UUID] + checkout_id: Mapped[str] = mapped_column(ForeignKey("checkout_checkout.id")) + + __table_args__ = ( + ForeignKeyConstraint( + ["volunteer_user_id", "edition_id"], + ["raid_volunteer.user_id", "raid_volunteer.edition_id"], + name="fk_raid_volunteer_checkout_volunteer", + ), + ) + + class RaidVolunteer(Base): __tablename__ = "raid_volunteer" user_id: Mapped[str] = mapped_column( @@ -276,6 +295,8 @@ class RaidVolunteer(Base): 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) + payment: Mapped[bool] = mapped_column(default=False) + t_shirt_payment: Mapped[bool] = mapped_column(default=False) user: Mapped[CoreUser] = relationship( "CoreUser", diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index fcf5b68cc0..0516aac53a 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -331,6 +331,14 @@ class RaidParticipantCheckout(BaseModel): model_config = ConfigDict(from_attributes=True) +class RaidVolunteerCheckout(BaseModel): + volunteer_user_id: str + edition_id: UUID + checkout_id: str + + model_config = ConfigDict(from_attributes=True) + + class RaidEditionBase(BaseModel): name: str year: int @@ -376,6 +384,8 @@ class RaidVolunteerBase(BaseModel): is_special_driver: bool = False is_utility_vehicle_driver: bool = False is_parcours_helper: bool = False + payment: bool = False + t_shirt_payment: bool = False def _validate_car_seats(self): @@ -395,6 +405,8 @@ class RaidVolunteerCreate(RaidVolunteerBase): created_at: datetime validated: bool = False cancelled: bool = False + payment: bool = False + t_shirt_payment: bool = False _check_car_seats_consistency = model_validator(mode="after")(_validate_car_seats) @@ -405,6 +417,8 @@ class RaidVolunteer(RaidVolunteerBase): created_at: datetime validated: bool cancelled: bool + payment: bool + t_shirt_payment: bool user: CoreUser model_config = ConfigDict(from_attributes=True) diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index 384e486b57..8b7a2b37a9 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -60,43 +60,87 @@ async def validate_payment( checkout_id = checkout_payment.checkout_id hyperion_error_logger.info(f"RAID: Callback Checkout id {checkout_id}") + # Try participant checkout first participant_checkout = await cruds_raid.get_participant_checkout_by_checkout_id( str(checkout_id), db, ) - if not participant_checkout: - raise RaidPayementError(checkout_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_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_user_id, - edition_id, - db, - ) - elif prices.t_shirt_price and ( - ( - prices.student_price - and paid_amount == prices.student_price + prices.t_shirt_price - ) - or ( - prices.external_price - and paid_amount == prices.external_price + prices.t_shirt_price - ) - ): - 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") + if participant_checkout: + 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_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_user_id, + edition_id, + db, + ) + elif prices.t_shirt_price and ( + ( + prices.student_price + and paid_amount == prices.student_price + prices.t_shirt_price + ) + or ( + prices.external_price + and paid_amount == prices.external_price + prices.t_shirt_price + ) + ): + 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") + return + + # Try volunteer checkout + volunteer_checkout = await cruds_raid.get_volunteer_checkout_by_checkout_id( + str(checkout_id), + db, + ) + if volunteer_checkout: + volunteer_user_id = volunteer_checkout.volunteer_user_id + edition_id = volunteer_checkout.edition_id + prices = await get_core_data(coredata_raid.RaidPrice, db) + if prices.volunteer_price and paid_amount == prices.volunteer_price: + await cruds_raid.confirm_volunteer_payment( + volunteer_user_id, + edition_id, + db, + ) + elif prices.t_shirt_price and paid_amount == prices.t_shirt_price: + await cruds_raid.confirm_volunteer_t_shirt_payment( + volunteer_user_id, + edition_id, + db, + ) + elif ( + prices.t_shirt_price + and prices.volunteer_price + and (paid_amount == prices.volunteer_price + prices.t_shirt_price) + ): + await cruds_raid.confirm_volunteer_payment( + volunteer_user_id, + edition_id, + db, + ) + await cruds_raid.confirm_volunteer_t_shirt_payment( + volunteer_user_id, + edition_id, + db, + ) + else: + hyperion_error_logger.error("Invalid payment amount") + return + + hyperion_error_logger.error(f"No checkout found for id {checkout_id}") + raise RaidPayementError(checkout_id) async def set_team_number( @@ -311,3 +355,28 @@ def calculate_raid_payment( checkout_name += " + " checkout_name += "T Shirt taille" + participant.t_shirt_size.value return price, checkout_name + + +def calculate_volunteer_payment( + volunteer: schemas_raid.RaidVolunteer, + raid_prices: coredata_raid.RaidPrice, +): + if not raid_prices.volunteer_price or not raid_prices.t_shirt_price: + raise HTTPException(status_code=404, detail="Volunteer prices not set.") + + price = 0 + checkout_name = "" + + if not volunteer.payment: + price += raid_prices.volunteer_price + checkout_name = "Inscription Raid - Bénévole" + if ( + volunteer.t_shirt_size + and volunteer.t_shirt_size != Size.None_ + and not volunteer.t_shirt_payment + ): + price += raid_prices.t_shirt_price + if not checkout_name: + checkout_name += " + " + checkout_name += "T Shirt taille" + volunteer.t_shirt_size.value + return price, checkout_name diff --git a/migrations/versions/67-raid-payment-for-volunteer.py b/migrations/versions/67-raid-payment-for-volunteer.py new file mode 100644 index 0000000000..52ce5ee524 --- /dev/null +++ b/migrations/versions/67-raid-payment-for-volunteer.py @@ -0,0 +1,84 @@ +"""Add payment fields to raid_volunteer and create raid_volunteer_checkout table + +Create Date: 2026-09-04 14:49:46.689444 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1638e8a71c27" +down_revision: str | None = "e0e6f306bed7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "raid_volunteer_checkout", + sa.Column("id", sa.String(), nullable=False), + sa.Column("volunteer_user_id", sa.String(), nullable=False), + sa.Column("edition_id", sa.Uuid(), nullable=False), + sa.Column("checkout_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["checkout_id"], ["checkout_checkout.id"]), + sa.ForeignKeyConstraint( + ["volunteer_user_id", "edition_id"], + ["raid_volunteer.user_id", "raid_volunteer.edition_id"], + name="fk_raid_volunteer_checkout_volunteer", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_raid_volunteer_checkout_id"), + "raid_volunteer_checkout", + ["id"], + unique=False, + ) + # Add columns with default values for existing rows + op.add_column( + "raid_volunteer", + sa.Column("payment", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + "raid_volunteer", + sa.Column( + "t_shirt_payment", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("raid_volunteer", "t_shirt_payment") + op.drop_column("raid_volunteer", "payment") + op.drop_index( + op.f("ix_raid_volunteer_checkout_id"), + table_name="raid_volunteer_checkout", + ) + op.drop_table("raid_volunteer_checkout") + # ### end Alembic commands ### + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/tests/modules/raid/test_utils_raid_extended.py b/tests/modules/raid/test_utils_raid_extended.py index 5a324e73b3..a293b5b6f9 100644 --- a/tests/modules/raid/test_utils_raid_extended.py +++ b/tests/modules/raid/test_utils_raid_extended.py @@ -145,6 +145,9 @@ async def test_validate_payment_raised_when_checkout_not_found(): mock_cruds.get_participant_checkout_by_checkout_id = AsyncMock( return_value=None, ) + mock_cruds.get_volunteer_checkout_by_checkout_id = AsyncMock( + return_value=None, + ) with pytest.raises(RaidPayementError) as exc_info: await validate_payment(checkout_payment, AsyncMock()) From b1c2a131d856ed87ba0a3499019412126323e81e Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:54:36 +0200 Subject: [PATCH 27/36] fix: rebase migration --- ...ecurity-file-consent.py => 67-raid-security-file-consent.py} | 2 +- ...ayment-for-volunteer.py => 68-raid-payment-for-volunteer.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{66-raid-security-file-consent.py => 67-raid-security-file-consent.py} (96%) rename migrations/versions/{67-raid-payment-for-volunteer.py => 68-raid-payment-for-volunteer.py} (100%) diff --git a/migrations/versions/66-raid-security-file-consent.py b/migrations/versions/67-raid-security-file-consent.py similarity index 96% rename from migrations/versions/66-raid-security-file-consent.py rename to migrations/versions/67-raid-security-file-consent.py index 24954dd36c..df9d486e08 100644 --- a/migrations/versions/66-raid-security-file-consent.py +++ b/migrations/versions/67-raid-security-file-consent.py @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision: str = "e0e6f306bed7" -down_revision: str | None = "dd905b1f5f57" +down_revision: str | None = "320892a84fd8" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/migrations/versions/67-raid-payment-for-volunteer.py b/migrations/versions/68-raid-payment-for-volunteer.py similarity index 100% rename from migrations/versions/67-raid-payment-for-volunteer.py rename to migrations/versions/68-raid-payment-for-volunteer.py From b1107539463f2206eb03c6747c1b026854eaacd0 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Thu, 10 Sep 2026 23:15:04 +0200 Subject: [PATCH 28/36] feat: adding scholarship price --- app/modules/raid/coredata_raid.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/modules/raid/coredata_raid.py b/app/modules/raid/coredata_raid.py index 094c005780..b2b7cfdc8d 100644 --- a/app/modules/raid/coredata_raid.py +++ b/app/modules/raid/coredata_raid.py @@ -23,5 +23,6 @@ class RaidPrice(core_data.BaseCoreData): student_price: int | None = None partner_price: int | None = None external_price: int | None = None + scholarship_price: int | None = None t_shirt_price: int | None = None volunteer_price: int | None = None From b332b73131be591f694aa2bfdae157c8fe0d42d6 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Thu, 10 Sep 2026 23:17:16 +0200 Subject: [PATCH 29/36] feat: adding document type --- app/modules/raid/raid_type.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/modules/raid/raid_type.py b/app/modules/raid/raid_type.py index 1eb42f1f7d..00c4b3dabb 100644 --- a/app/modules/raid/raid_type.py +++ b/app/modules/raid/raid_type.py @@ -9,6 +9,9 @@ class DocumentType(StrEnum): studentCard = "studentCard" # the student card of the participant raidRules = "raidRules" # the rules of the raid parentAuthorization = "parentAuthorization" # the parent authorization + schoolAuthorization = ( + "schoolAuthorization" # the school authorization for scholarship participants + ) class Size(StrEnum): # for the T-shirt and the bike From 5cbcbfb8cac45504014b9dbb1749826d32ce4134 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Thu, 10 Sep 2026 23:15:59 +0200 Subject: [PATCH 30/36] feat: adding scholarship status and document to model --- app/modules/raid/models_raid.py | 10 ++++++++++ app/modules/raid/schemas_raid.py | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/app/modules/raid/models_raid.py b/app/modules/raid/models_raid.py index 237c143fff..b247578431 100644 --- a/app/modules/raid/models_raid.py +++ b/app/modules/raid/models_raid.py @@ -165,10 +165,20 @@ class RaidParticipant(Base): foreign_keys=[parent_authorization_id], init=False, ) + school_authorization_id: Mapped[str | None] = mapped_column( + ForeignKey("raid_document.id"), + default=None, + ) + school_authorization: Mapped[Document | None] = relationship( + "app.modules.raid.models_raid.Document", + foreign_keys=[school_authorization_id], + init=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) is_minor: Mapped[bool] = mapped_column(default=False) + has_scholarship: Mapped[bool] = mapped_column(default=False) user: Mapped[CoreUser] = relationship( "CoreUser", diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 0516aac53a..db0eb91d14 100644 --- a/app/modules/raid/schemas_raid.py +++ b/app/modules/raid/schemas_raid.py @@ -102,10 +102,12 @@ class RaidParticipantCreate(BaseModel): student_card_id: str | None = None raid_rules_id: str | None = None parent_authorization_id: str | None = None + school_authorization_id: str | None = None attestation_on_honour: bool = False payment: bool = False t_shirt_payment: bool = False is_minor: bool = False + has_scholarship: bool = False class RaidParticipantPreview(RaidParticipantBase): @@ -142,8 +144,11 @@ class RaidParticipantRestricted(RaidParticipantPreview): raid_rules: Document | None = None parent_authorization_id: str | None = None parent_authorization: Document | None = None + school_authorization_id: str | None = None + school_authorization: Document | None = None attestation_on_honour: bool is_minor: bool + has_scholarship: bool @computed_field # type: ignore[prop-decorator] @property @@ -185,6 +190,8 @@ class RaidParticipantUpdate(BaseModel): student_card_id: str | None = None raid_rules_id: str | None = None parent_authorization_id: str | None = None + school_authorization_id: str | None = None + has_scholarship: bool | None = None @field_validator("situation", mode="before") @classmethod From 985fa2142901f2d2c46a94b538cc283707b8ac48 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 11 Sep 2026 13:58:41 +0200 Subject: [PATCH 31/36] feat: adding scholarship price migration --- .../versions/69-raid-scholarship-price.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 migrations/versions/69-raid-scholarship-price.py diff --git a/migrations/versions/69-raid-scholarship-price.py b/migrations/versions/69-raid-scholarship-price.py new file mode 100644 index 0000000000..9664384bfa --- /dev/null +++ b/migrations/versions/69-raid-scholarship-price.py @@ -0,0 +1,107 @@ +"""69-raid-scholarship-price + +Create Date: 2026-09-11 10:28:18.162848 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "cfdbcbca654a" +down_revision: str | None = "1638e8a71c27" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "raid_participant", + sa.Column("school_authorization_id", sa.String(), nullable=True), + ) + op.add_column( + "raid_participant", + sa.Column( + "has_scholarship", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + op.create_foreign_key( + "raid_participant_school_authorization_id_fkey", + "raid_participant", + "raid_document", + ["school_authorization_id"], + ["id"], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "raid_participant_school_authorization_id_fkey", + "raid_participant", + type_="foreignkey", + ) + op.drop_column("raid_participant", "has_scholarship") + op.drop_column("raid_participant", "school_authorization_id") + # ### end Alembic commands ### + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + """Verify the new scholarship columns exist after the upgrade.""" + columns = { + row[0] + for row in alembic_connection.execute( + sa.text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = 'raid_participant'", + ), + ).fetchall() + } + assert "school_authorization_id" in columns + assert "has_scholarship" in columns + + # `has_scholarship` must be NOT NULL with a false default for existing rows. + row = alembic_connection.execute( + sa.text( + "SELECT is_nullable, column_default FROM information_schema.columns " + "WHERE table_name = 'raid_participant' AND column_name = 'has_scholarship'", + ), + ).fetchone() + assert row is not None + is_nullable, column_default = row + assert is_nullable == "NO" + assert column_default is not None + assert "false" in column_default.lower() + + # The school authorization column must reference raid_document. + fk = alembic_connection.execute( + sa.text( + "SELECT 1 FROM information_schema.table_constraints tc " + "JOIN information_schema.key_column_usage kcu " + "ON tc.constraint_name = kcu.constraint_name " + "WHERE tc.table_name = 'raid_participant' " + "AND tc.constraint_type = 'FOREIGN KEY' " + "AND kcu.column_name = 'school_authorization_id'", + ), + ).fetchone() + assert fk is not None From 16b94810ba1186f32fd03aa22c4e71027c8c231b Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 11 Sep 2026 13:57:59 +0200 Subject: [PATCH 32/36] feat: adding school authorization to queryable documents --- app/modules/raid/cruds_raid.py | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index 5f30cfcc99..66cde1c3b7 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -19,6 +19,7 @@ models_raid.RaidParticipant.student_card, models_raid.RaidParticipant.raid_rules, models_raid.RaidParticipant.parent_authorization, + models_raid.RaidParticipant.school_authorization, models_raid.RaidParticipant.user, ] @@ -37,26 +38,7 @@ async def create_participant( ) -> None: db.add( models_raid.RaidParticipant( - user_id=participant.user_id, - edition_id=participant.edition_id, - status=participant.status, - address=participant.address, - bike_size=participant.bike_size, - t_shirt_size=participant.t_shirt_size, - situation=participant.situation, - other_school=participant.other_school, - company=participant.company, - diet=participant.diet, - id_card_id=participant.id_card_id, - medical_certificate_id=participant.medical_certificate_id, - security_file_id=participant.security_file_id, - student_card_id=participant.student_card_id, - raid_rules_id=participant.raid_rules_id, - parent_authorization_id=participant.parent_authorization_id, - attestation_on_honour=participant.attestation_on_honour, - payment=participant.payment, - t_shirt_payment=participant.t_shirt_payment, - is_minor=participant.is_minor, + **participant.model_dump(), ), ) await db.flush() @@ -618,6 +600,7 @@ async def get_user_by_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, + models_raid.RaidParticipant.school_authorization_id == document_id, ), ) .options( From 8966fbd1a1ddc8a897b1b407d46c1846c9414cc1 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Thu, 10 Sep 2026 23:18:00 +0200 Subject: [PATCH 33/36] feat: adding check in validation checker --- app/modules/raid/utils/validation_checker.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py index e0944d786d..e8f5843a22 100644 --- a/app/modules/raid/utils/validation_checker.py +++ b/app/modules/raid/utils/validation_checker.py @@ -116,6 +116,11 @@ def _check_all_documents_accepted( participant.parent_authorization, "parent authorization", ) + if participant.has_scholarship: + _check_document_accepted( + participant.school_authorization, + "school authorization", + ) def _check_document_accepted( @@ -208,6 +213,7 @@ class _ParticipantContext: situation: Situation | None is_minor: bool + has_scholarship: bool = False @dataclass(frozen=True) @@ -253,6 +259,11 @@ class _DocumentRule: applies=lambda c: c.is_minor, counts_temporary=True, ), + _DocumentRule( + "school_authorization", + applies=lambda c: c.has_scholarship, + counts_temporary=True, + ), ) # Profile fields that each count one slot when set on the participant. @@ -271,6 +282,7 @@ def _context( return _ParticipantContext( situation=participant.situation, is_minor=participant.is_minor, + has_scholarship=participant.has_scholarship, ) From 6b7e1c4474ff8ac506898980ad1457b95e17eba1 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Thu, 10 Sep 2026 23:18:20 +0200 Subject: [PATCH 34/36] feat: updating participant price calculation --- app/modules/raid/utils/utils_raid.py | 46 +++++++++++++++++++--------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/app/modules/raid/utils/utils_raid.py b/app/modules/raid/utils/utils_raid.py index 8b7a2b37a9..44cd4eef07 100644 --- a/app/modules/raid/utils/utils_raid.py +++ b/app/modules/raid/utils/utils_raid.py @@ -9,7 +9,12 @@ from app.core.payment import schemas_payment from app.modules.raid import coredata_raid, cruds_raid, schemas_raid -from app.modules.raid.raid_type import Difficulty, Situation, Size +from app.modules.raid.raid_type import ( + Difficulty, + DocumentValidation, + Situation, + Size, +) from app.modules.raid.utils.pdf.conversion_utils import ( get_difficulty_label, get_meeting_place_label, @@ -69,9 +74,16 @@ async def validate_payment( 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 - ): + inscription_prices = [ + price + for price in ( + prices.student_price, + prices.external_price, + prices.scholarship_price, + ) + if price + ] + if any(paid_amount == price for price in inscription_prices): 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( @@ -79,15 +91,8 @@ async def validate_payment( edition_id, db, ) - elif prices.t_shirt_price and ( - ( - prices.student_price - and paid_amount == prices.student_price + prices.t_shirt_price - ) - or ( - prices.external_price - and paid_amount == prices.external_price + prices.t_shirt_price - ) + elif prices.t_shirt_price and any( + paid_amount == price + prices.t_shirt_price for price in inscription_prices ): await cruds_raid.confirm_payment(participant_user_id, edition_id, db) await cruds_raid.confirm_t_shirt_payment( @@ -329,19 +334,32 @@ def calculate_raid_payment( not raid_prices.student_price or not raid_prices.t_shirt_price or not raid_prices.external_price + or not raid_prices.scholarship_price ): raise HTTPException(status_code=404, detail="Prices not set.") price = 0 checkout_name = "" + # The scholarship rate only applies once the school authorization document + # has been accepted by an admin; the flag alone is not enough. + has_validated_scholarship = ( + participant.has_scholarship + and participant.school_authorization is not None + and participant.school_authorization.validation == DocumentValidation.accepted + ) + if not participant.payment: - if ( + if has_validated_scholarship: + price += raid_prices.scholarship_price + checkout_name = "Inscription Raid - Tarif boursier" + elif ( participant.situation in (Situation.centrale, Situation.otherSchool) and participant.student_card_id is not None ): price += raid_prices.student_price checkout_name = "Inscription Raid - Tarif étudiant" + else: price += raid_prices.external_price checkout_name = "Inscription Raid - Tarif externe" From 6cbfc0d2146358c40b15159a772bb91e7b0b9d1e Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Thu, 10 Sep 2026 23:19:54 +0200 Subject: [PATCH 35/36] feat: cleaning casting --- app/modules/raid/endpoints_raid.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index f12971d099..2739d5caeb 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -225,20 +225,7 @@ async def get_participant_by_id( participant = await get_participant_complete_or_404(user_id, edition.id, db) - return schemas_raid.RaidParticipantRestrictedComplete( - user_id=participant.user_id, - edition_id=participant.edition_id, - status=participant.status, - bike_size=participant.bike_size, - t_shirt_size=participant.t_shirt_size, - situation=participant.situation, - payment=participant.payment, - t_shirt_payment=participant.t_shirt_payment, - user=participant.user, - validation_progress=participant.validation_progress, - attestation_on_honour=participant.attestation_on_honour, - is_minor=participant.is_minor, - ) + return schemas_raid.RaidParticipantRestrictedComplete(**participant.model_dump()) @module.router.post( @@ -317,6 +304,7 @@ async def update_participant( ("student_card_id", "student_card"), ("raid_rules_id", "raid_rules"), ("parent_authorization_id", "parent_authorization"), + ("school_authorization_id", "school_authorization"), ): doc_id = getattr(participant_update, attr) if doc_id and not await cruds_raid.get_document_by_id(doc_id, db): @@ -727,6 +715,7 @@ async def upload_document( DocumentType.studentCard: "student_card_id", DocumentType.raidRules: "raid_rules_id", DocumentType.parentAuthorization: "parent_authorization_id", + DocumentType.schoolAuthorization: "school_authorization_id", }[document_type] await cruds_raid.assign_document( user.id, @@ -1239,6 +1228,7 @@ async def get_payment_url( not raid_prices.student_price or not raid_prices.t_shirt_price or not raid_prices.external_price + or not raid_prices.scholarship_price ): raise HTTPException(status_code=404, detail="Prices not set.") From b6517d16c461a9bfd9a20e5b5cc5c4782f4b02c5 Mon Sep 17 00:00:00 2001 From: maximeroucher Date: Fri, 11 Sep 2026 13:58:59 +0200 Subject: [PATCH 36/36] feat: updating tests --- app/modules/raid/endpoints_raid.py | 28 +- tests/modules/raid/test_pdf_generation.py | 2 + tests/modules/raid/test_schemas_raid.py | 49 ++ tests/modules/raid/test_utils_raid.py | 94 +++- .../modules/raid/test_utils_raid_extended.py | 167 ++++++ tests/modules/raid/test_validation_checker.py | 111 +++- tests/modules/test_raid.py | 476 +++++++++++++++++- 7 files changed, 917 insertions(+), 10 deletions(-) diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 2739d5caeb..d93a201e01 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -5,6 +5,7 @@ from anyio import Path from fastapi import Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.core.groups.groups_type import AccountType @@ -1033,10 +1034,8 @@ async def join_team( raise HTTPException(status_code=400, detail="Invite for a different edition") 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) + if user_team and user_team.second_id: + raise HTTPException(status_code=403, detail="You are already in a team.") team = await cruds_raid.get_team_by_id(invite_token.team_id, db) if not team: @@ -1049,8 +1048,25 @@ async def join_team( detail="You are already the captain of this team.", ) - await cruds_raid.delete_invite_token(invite_token.id, db) - await cruds_raid.update_team_second_id(team.id, user.id, db) + # A participant may already have an incomplete team and an active invite. + # Remove that invite before deleting the old team, otherwise the foreign + # key from raid_invite.team_id causes an IntegrityError (HTTP 500). + if user_team: + await cruds_raid.delete_team_invite_tokens(user_team.id, db) + await cruds_raid.delete_team(user_team.id, db) + + try: + await cruds_raid.delete_invite_token(invite_token.id, db) + await cruds_raid.update_team_second_id(team.id, user.id, db) + except IntegrityError as error: + # Two invitees can pass the capacity check concurrently. Convert the + # database unique-constraint failure into a recoverable API response. + await db.rollback() + hyperion_error_logger.info("Raid team join conflict", exc_info=error) + raise HTTPException( + status_code=409, + detail="This team was just joined by another participant.", + ) from error @module.router.post( diff --git a/tests/modules/raid/test_pdf_generation.py b/tests/modules/raid/test_pdf_generation.py index b557f73f16..f3b7b5801c 100644 --- a/tests/modules/raid/test_pdf_generation.py +++ b/tests/modules/raid/test_pdf_generation.py @@ -30,6 +30,8 @@ def _create_mock_participant(user_id: str | None = None) -> MagicMock: participant.user_id = user_id participant.situation = Situation.centrale participant.is_minor = False + participant.school_authorization_id = None + participant.has_scholarship = False participant.student_card_id = None participant.id_card = None participant.medical_certificate = None diff --git a/tests/modules/raid/test_schemas_raid.py b/tests/modules/raid/test_schemas_raid.py index 5d44238a37..62bfe21c05 100644 --- a/tests/modules/raid/test_schemas_raid.py +++ b/tests/modules/raid/test_schemas_raid.py @@ -75,6 +75,55 @@ def test_participant_update_allows_empty_body() -> None: schemas_raid.RaidParticipantUpdate() +# -- RaidParticipant scholarship fields ----------------------------------- + + +def test_participant_create_scholarship_defaults_false() -> None: + u = schemas_raid.RaidParticipantCreate( + user_id="u1", + edition_id=uuid4(), + status=RaidRegistrationStatus.draft, + ) + assert u.has_scholarship is False + assert u.school_authorization_id is None + + +def test_participant_create_accepts_scholarship_fields() -> None: + doc_id = str(uuid4()) + u = schemas_raid.RaidParticipantCreate( + user_id="u1", + edition_id=uuid4(), + status=RaidRegistrationStatus.draft, + has_scholarship=True, + school_authorization_id=doc_id, + ) + assert u.has_scholarship is True + assert u.school_authorization_id == doc_id + + +def test_participant_update_accepts_school_authorization_id() -> None: + doc_id = str(uuid4()) + u = schemas_raid.RaidParticipantUpdate(school_authorization_id=doc_id) + assert u.school_authorization_id == doc_id + + +def test_participant_update_accepts_has_scholarship() -> None: + """The update schema carries the scholarship flag (admin-only upstream).""" + u = schemas_raid.RaidParticipantUpdate(has_scholarship=True) + assert u.has_scholarship is True + + unset = schemas_raid.RaidParticipantUpdate() + assert unset.has_scholarship is None # absent = don't touch + + +def test_participant_restricted_requires_scholarship_flag() -> None: + """The read schema exposes has_scholarship as a required field.""" + fields = schemas_raid.RaidParticipantRestricted.model_fields + assert "has_scholarship" in fields + assert "school_authorization_id" in fields + assert fields["has_scholarship"].is_required() + + def test_participant_update_preserves_other_school_when_other() -> None: u = schemas_raid.RaidParticipantUpdate( situation=Situation.other, diff --git a/tests/modules/raid/test_utils_raid.py b/tests/modules/raid/test_utils_raid.py index a4c902443c..8618114722 100644 --- a/tests/modules/raid/test_utils_raid.py +++ b/tests/modules/raid/test_utils_raid.py @@ -21,7 +21,12 @@ 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.raid_type import ( + Difficulty, + DocumentValidation, + Situation, + Size, +) from app.modules.raid.utils.utils_raid import ( calculate_raid_payment, get_all_security_files_zip, @@ -83,21 +88,106 @@ def prices() -> coredata_raid.RaidPrice: student_price=50, t_shirt_price=15, external_price=90, + volunteer_price=0, + scholarship_price=25, ) def _participant(**kwargs): + school_authorization = kwargs.pop("school_authorization", None) defaults: dict[str, Any] = { "user_id": str(uuid4()), "edition_id": uuid4(), "payment": False, "t_shirt_payment": False, "t_shirt_size": None, + "has_scholarship": False, "situation": None, "student_card_id": None, } defaults.update(kwargs) - return RaidParticipant(**defaults) + participant = RaidParticipant(**defaults) + participant.school_authorization = school_authorization + return participant + + +def _accepted_doc() -> Any: + """A school authorization document already accepted by an admin.""" + doc = Mock() + doc.validation = DocumentValidation.accepted + return doc + + +def test_payment_scholarship(prices) -> None: + p = _participant( + situation=Situation.centrale, + student_card_id=str(uuid.uuid4()), + has_scholarship=True, + school_authorization=_accepted_doc(), + ) + price, label = calculate_raid_payment(p, prices) + assert price == 25 + assert "boursier" in label + + +def test_payment_scholarship_takes_precedence_over_external(prices) -> None: + """Scholarship wins even without any student card / central situation.""" + p = _participant( + situation=Situation.other, + has_scholarship=True, + school_authorization=_accepted_doc(), + ) + price, label = calculate_raid_payment(p, prices) + assert price == 25 + assert "boursier" in label + + +def test_payment_scholarship_with_tshirt(prices) -> None: + p = _participant( + situation=Situation.other, + has_scholarship=True, + school_authorization=_accepted_doc(), + t_shirt_size=Size.M, + ) + price, label = calculate_raid_payment(p, prices) + assert price == 40 # 25 scholarship + 15 t-shirt + assert "boursier" in label + + +def test_payment_scholarship_without_document_is_external(prices) -> None: + """The flag alone must not unlock the discounted price.""" + p = _participant(situation=Situation.other, has_scholarship=True) + price, label = calculate_raid_payment(p, prices) + assert price == 90 + assert "externe" in label + + +def test_payment_scholarship_with_pending_document_is_external(prices) -> None: + """A pending (not yet accepted) document does not grant the discount.""" + doc = Mock() + doc.validation = DocumentValidation.pending + p = _participant( + situation=Situation.other, + has_scholarship=True, + school_authorization=doc, + ) + price, label = calculate_raid_payment(p, prices) + assert price == 90 + assert "externe" in label + + +def test_payment_raises_if_scholarship_price_missing() -> None: + """Prices must explicitly include scholarship_price.""" + bad_prices = coredata_raid.RaidPrice( + student_price=50, + t_shirt_price=15, + external_price=90, + scholarship_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 def test_payment_centrale_with_student_card(prices) -> None: diff --git a/tests/modules/raid/test_utils_raid_extended.py b/tests/modules/raid/test_utils_raid_extended.py index a293b5b6f9..8d1f6386d7 100644 --- a/tests/modules/raid/test_utils_raid_extended.py +++ b/tests/modules/raid/test_utils_raid_extended.py @@ -16,6 +16,7 @@ from app.modules.raid import coredata_raid, schemas_raid from app.modules.raid.raid_type import ( Difficulty, + DocumentValidation, Situation, Size, ) @@ -303,11 +304,14 @@ def test_calculate_raid_payment_student_with_card(): participant.payment = False participant.t_shirt_size = None participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, checkout_name = calculate_raid_payment(participant, prices) @@ -324,11 +328,14 @@ def test_calculate_raid_payment_student_without_card(): participant.payment = False participant.t_shirt_size = None participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, checkout_name = calculate_raid_payment(participant, prices) @@ -345,11 +352,14 @@ def test_calculate_raid_payment_other_school(): participant.payment = False participant.t_shirt_size = None participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, checkout_name = calculate_raid_payment(participant, prices) @@ -366,11 +376,14 @@ def test_calculate_raid_payment_corporate_partner(): participant.payment = False participant.t_shirt_size = None participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, _ = calculate_raid_payment(participant, prices) @@ -378,6 +391,54 @@ def test_calculate_raid_payment_corporate_partner(): assert price == 90.0 # Corporate partner is always external +def test_calculate_raid_payment_scholarship(): + """Test calculate_raid_payment for participant with scholarship.""" + participant = Mock(spec=schemas_raid.RaidParticipant) + participant.situation = Situation.centrale + participant.student_card_id = "card_123" + participant.payment = False + participant.t_shirt_size = None + participant.t_shirt_payment = False + participant.has_scholarship = True + participant.school_authorization = Mock(validation=DocumentValidation.accepted) + + prices = coredata_raid.RaidPrice( + student_price=50.0, + t_shirt_price=15.0, + external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, + ) + + price, _ = calculate_raid_payment(participant, prices) + + assert price == 25.0 # Scholarship price applies + + +def test_calculate_raid_payment_scholarship_without_accepted_document(): + """Flag without an accepted school authorization stays on external price.""" + participant = Mock(spec=schemas_raid.RaidParticipant) + participant.situation = Situation.other + participant.student_card_id = None + participant.payment = False + participant.t_shirt_size = None + participant.t_shirt_payment = False + participant.has_scholarship = True + participant.school_authorization = None + + prices = coredata_raid.RaidPrice( + student_price=50.0, + t_shirt_price=15.0, + external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, + ) + + price, _ = calculate_raid_payment(participant, prices) + + assert price == 90.0 # No accepted document -> external price + + def test_calculate_raid_payment_with_tshirt(): """Test calculate_raid_payment includes t-shirt when applicable.""" participant = Mock(spec=schemas_raid.RaidParticipant) @@ -386,11 +447,14 @@ def test_calculate_raid_payment_with_tshirt(): participant.payment = False participant.t_shirt_size = Size.L participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, _ = calculate_raid_payment(participant, prices) @@ -406,11 +470,14 @@ def test_calculate_raid_payment_already_paid(): participant.payment = True participant.t_shirt_size = None participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, _ = calculate_raid_payment(participant, prices) @@ -426,11 +493,14 @@ def test_calculate_raid_payment_tshirt_alone(): participant.payment = True participant.t_shirt_size = Size.L participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, _ = calculate_raid_payment(participant, prices) @@ -446,11 +516,14 @@ def test_calculate_raid_payment_fully_paid(): participant.payment = True participant.t_shirt_size = Size.L participant.t_shirt_payment = True + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, _ = calculate_raid_payment(participant, prices) @@ -466,6 +539,7 @@ def test_calculate_raid_payment_invalid_price(): participant.payment = False participant.t_shirt_size = None participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=None, @@ -491,11 +565,14 @@ def test_calculate_raid_payment_situation_none(): participant.payment = False participant.t_shirt_size = None participant.t_shirt_payment = False + participant.has_scholarship = False prices = coredata_raid.RaidPrice( student_price=50.0, t_shirt_price=15.0, external_price=90.0, + scholarship_price=25.0, + volunteer_price=0.0, ) price, _ = calculate_raid_payment(participant, prices) @@ -525,6 +602,8 @@ async def test_validate_payment_all_combinations(): prices.student_price = 50.0 prices.external_price = 90.0 prices.t_shirt_price = 15.0 + prices.scholarship_price = 25.0 + prices.volunteer_price = 0.0 with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds: mock_cruds.get_participant_checkout_by_checkout_id = AsyncMock( @@ -550,3 +629,91 @@ async def test_validate_payment_all_combinations(): participant_checkout.edition_id, db, ) + + +@pytest.mark.asyncio +async def test_validate_payment_scholarship_amount_confirms_payment(): + """The payment callback recognizes the scholarship price on its own.""" + db = AsyncMock() + checkout_payment = schemas_payment.CheckoutPayment( + id=uuid4(), + checkout_id=uuid4(), + paid_amount=25.0, # scholarship price + ) + + participant_checkout = Mock() + participant_checkout.participant_user_id = "user_scholar" + participant_checkout.edition_id = uuid4() + + prices = Mock() + prices.student_price = 50.0 + prices.external_price = 90.0 + prices.t_shirt_price = 15.0 + prices.scholarship_price = 25.0 + prices.volunteer_price = 0.0 + + with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds: + mock_cruds.get_participant_checkout_by_checkout_id = AsyncMock( + return_value=participant_checkout, + ) + mock_cruds.confirm_payment = AsyncMock() + mock_cruds.confirm_t_shirt_payment = AsyncMock() + + with patch( + "app.modules.raid.utils.utils_raid.get_core_data", + new=AsyncMock(return_value=prices), + ): + await validate_payment(checkout_payment, db) + + mock_cruds.confirm_payment.assert_called_once_with( + "user_scholar", + participant_checkout.edition_id, + db, + ) + mock_cruds.confirm_t_shirt_payment.assert_not_called() + + +@pytest.mark.asyncio +async def test_validate_payment_scholarship_with_tshirt_confirms_both(): + """The payment callback recognizes scholarship + t-shirt combination.""" + db = AsyncMock() + checkout_payment = schemas_payment.CheckoutPayment( + id=uuid4(), + checkout_id=uuid4(), + paid_amount=40.0, # scholarship + t-shirt + ) + + participant_checkout = Mock() + participant_checkout.participant_user_id = "user_scholar" + participant_checkout.edition_id = uuid4() + + prices = Mock() + prices.student_price = 50.0 + prices.external_price = 90.0 + prices.t_shirt_price = 15.0 + prices.scholarship_price = 25.0 + prices.volunteer_price = 0.0 + + with patch("app.modules.raid.utils.utils_raid.cruds_raid") as mock_cruds: + mock_cruds.get_participant_checkout_by_checkout_id = AsyncMock( + return_value=participant_checkout, + ) + mock_cruds.confirm_payment = AsyncMock() + mock_cruds.confirm_t_shirt_payment = AsyncMock() + + with patch( + "app.modules.raid.utils.utils_raid.get_core_data", + new=AsyncMock(return_value=prices), + ): + await validate_payment(checkout_payment, db) + + mock_cruds.confirm_payment.assert_called_once_with( + "user_scholar", + participant_checkout.edition_id, + db, + ) + mock_cruds.confirm_t_shirt_payment.assert_called_once_with( + "user_scholar", + participant_checkout.edition_id, + db, + ) diff --git a/tests/modules/raid/test_validation_checker.py b/tests/modules/raid/test_validation_checker.py index 3ad0584746..a2aa77c596 100644 --- a/tests/modules/raid/test_validation_checker.py +++ b/tests/modules/raid/test_validation_checker.py @@ -53,12 +53,14 @@ def _make_validated_participant( is_minor: bool = False, with_student_card: bool | None = None, with_parent_auth: bool | None = None, + with_school_authorization: bool = True, 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, + has_scholarship: bool = False, ) -> Mock: """Assemble a participant that would pass every check by default.""" edition_id = edition_id or uuid4() @@ -72,6 +74,7 @@ def _make_validated_participant( participant.edition_id = edition_id participant.situation = situation participant.is_minor = is_minor + participant.has_scholarship = has_scholarship participant.attestation_on_honour = attestation participant.payment = payment participant.t_shirt_size = t_shirt_size @@ -85,6 +88,9 @@ def _make_validated_participant( participant.parent_authorization = ( _make_doc(DocumentValidation.accepted) if with_parent_auth else None ) + participant.school_authorization = ( + _make_doc(DocumentValidation.accepted) if with_school_authorization else None + ) participant.security_file = ( _make_security_file(security_contacts) if with_security_file else None ) @@ -214,6 +220,39 @@ def test_check_all_documents_accepted_requires_student_card_for_otherschool() -> assert exc_info.value.detail == "Missing student card" +def test_check_all_documents_accepted_requires_school_authorization_for_scholarship() -> ( + None +): + p = _make_validated_participant( + situation=Situation.centrale, + is_minor=False, + with_school_authorization=False, + has_scholarship=True, + ) + with pytest.raises(HTTPException) as exc_info: + validation_checker._check_all_documents_accepted(p) + assert exc_info.value.detail == "Missing school authorization" + + +def test_check_all_documents_accepted_rejects_school_authorization_not_accepted() -> ( + None +): + p = _make_validated_participant(has_scholarship=True) + p.school_authorization = _make_doc(DocumentValidation.pending) + with pytest.raises(HTTPException) as exc_info: + validation_checker._check_all_documents_accepted(p) + assert exc_info.value.detail == "Document school authorization is not accepted" + + +def test_check_all_documents_accepted_ignores_school_auth_without_scholarship() -> None: + """A missing school authorization is fine when has_scholarship is False.""" + p = _make_validated_participant( + has_scholarship=False, + with_school_authorization=False, + ) + validation_checker._check_all_documents_accepted(p) # no raise + + def test_check_all_documents_accepted_requires_parent_auth_when_minor() -> None: p = _make_validated_participant( situation=Situation.centrale, @@ -443,12 +482,18 @@ def test_count_total_required_documents_centrale() -> None: spec=models_raid.RaidParticipant, situation=Situation.centrale, is_minor=False, + has_scholarship=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) + p = Mock( + spec=models_raid.RaidParticipant, + situation=Situation.other, + is_minor=True, + has_scholarship=False, + ) assert validation_checker.count_total_required_documents(p) == 4 @@ -457,7 +502,32 @@ def test_count_total_required_documents_centrale_minor() -> None: spec=models_raid.RaidParticipant, situation=Situation.centrale, is_minor=True, + has_scholarship=False, + ) + assert validation_checker.count_total_required_documents(p) == 5 + + +def test_count_total_required_documents_other_minor_scholarship() -> None: + p = Mock( + spec=models_raid.RaidParticipant, + situation=Situation.other, + is_minor=True, + has_scholarship=True, + ) + # id_card + medical_certificate + raid_rules + parent_authorization + # + school_authorization (no student card for `other`) + assert validation_checker.count_total_required_documents(p) == 5 + + +def test_count_total_required_documents_centrale_scholarship() -> None: + p = Mock( + spec=models_raid.RaidParticipant, + situation=Situation.centrale, + is_minor=False, + has_scholarship=True, ) + # id_card + medical_certificate + raid_rules + student_card + # + school_authorization assert validation_checker.count_total_required_documents(p) == 5 @@ -467,8 +537,47 @@ def test_count_accepted_documents_all_present() -> None: assert validation_checker.count_accepted_documents(p) == 5 +def test_count_accepted_documents_scholarship_all_present() -> None: + p = _make_validated_participant(situation=Situation.centrale, has_scholarship=True) + # id_card + medical_certificate + raid_rules + student_card + school_authorization + assert validation_checker.count_accepted_documents(p) == 5 + + +def test_count_accepted_documents_scholarship_without_doc_not_counted() -> None: + p = _make_validated_participant( + situation=Situation.centrale, + has_scholarship=True, + with_school_authorization=False, + ) + # id_card + medical_certificate + raid_rules + student_card + assert validation_checker.count_accepted_documents(p) == 4 + + 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 + + +def test_compute_participant_progress_temporary_school_authorization_counts_half() -> ( + None +): + """A `temporary` school authorization scores half a slot for scholars.""" + complete = _make_validated_participant(has_scholarship=True) + partial = _make_validated_participant(has_scholarship=True) + partial.school_authorization = _make_doc(DocumentValidation.temporary) + + complete_progress = validation_checker.compute_participant_progress(complete) + partial_progress = validation_checker.compute_participant_progress(partial) + + # centrale + scholarship: 11 slots total, the school authorization slot + # is worth 0.5 instead of 1.0. + assert complete_progress - partial_progress == pytest.approx(100 * 0.5 / 11) + assert complete_progress > partial_progress > 0 + + +def test_compute_participant_progress_full_with_scholarship() -> None: + p = _make_validated_participant(has_scholarship=True) + progress = validation_checker.compute_participant_progress(p) + assert progress >= 70 diff --git a/tests/modules/test_raid.py b/tests/modules/test_raid.py index c7ebc3d28b..8ebbfcfb76 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -13,9 +13,10 @@ import pytest import pytest_asyncio from fastapi.testclient import TestClient -from sqlalchemy import update +from sqlalchemy import delete, update from app.core.groups import models_groups +from app.core.payment import cruds_payment, models_payment from app.core.users import cruds_users, models_users, schemas_users from app.modules.raid import coredata_raid, cruds_raid, models_raid, schemas_raid from app.modules.raid.endpoints_raid import RaidPermissions @@ -28,6 +29,7 @@ Situation, Size, ) +from app.utils.tools import save_bytes_as_data from tests.commons import ( add_coredata_to_db, add_object_to_db, @@ -35,6 +37,7 @@ create_groups_with_permissions, create_user_with_groups, get_TestingSessionLocal, + mocked_checkout_id, ) # --------------------------------------------------------------------------- @@ -102,6 +105,7 @@ async def init_objects() -> None: t_shirt_price=15, partner_price=70, external_price=90, + scholarship_price=25, ), ) await add_coredata_to_db(coredata_raid.RaidInformation()) @@ -412,6 +416,18 @@ def test_update_participant_invalid_document(client: TestClient) -> None: assert r.status_code == 404 +def test_update_participant_with_unknown_school_authorization( + client: TestClient, +) -> None: + """PATCH /participants validates the school authorization document too.""" + r = client.patch( + f"/raid/participants/{user_captain.id}", + json={"school_authorization_id": "does-not-exist"}, + headers={"Authorization": f"Bearer {token_captain}"}, + ) + assert r.status_code == 404 + + def test_submit_without_attestation_400(client: TestClient) -> None: r = client.post( f"/raid/participants/{user_captain.id}/submit", @@ -645,6 +661,24 @@ def test_upload_document(client: TestClient) -> None: assert r.status_code == 201 +def test_upload_school_authorization_document(client: TestClient) -> None: + """Uploading a school authorization assigns it to the participant.""" + r = client.post( + "/raid/document/schoolAuthorization", + files={"file": ("school_auth.pdf", b"blob", "application/pdf")}, + headers={"Authorization": f"Bearer {token_captain}"}, + ) + assert r.status_code == 201 + doc_id = r.json()["id"] + + r = client.get( + "/raid/participants/me", + headers={"Authorization": f"Bearer {token_captain}"}, + ) + assert r.status_code == 200 + assert r.json()["school_authorization_id"] == doc_id + + def test_validate_document_requires_admin(client: TestClient) -> None: r = client.post( f"/raid/document/{doc_pending.id}/validate?validation=accepted", @@ -854,6 +888,446 @@ def test_get_volunteer_me_after_delete(client: TestClient) -> None: assert r.status_code == 404 +# --------------------------------------------------------------------------- +# Scholarship flow (has_scholarship + school_authorization document) +# --------------------------------------------------------------------------- + + +async def _setup_scholarship_participant( + user: models_users.CoreUser, +) -> None: + """Promote an existing participant to a scholarship participant ready to validate.""" + # Team completeness requires a second member; give the team a bare one. + second_user = await create_user_with_groups([]) + await _set_user_identity( + second_user.id, + "+33672000099", + datetime.date(2000, 1, 31), + ) + + async with get_TestingSessionLocal()() as db: + docs = {} + for doc_type in ( + DocumentType.idCard, + DocumentType.medicalCertificate, + DocumentType.raidRules, + DocumentType.studentCard, + DocumentType.schoolAuthorization, + ): + doc = models_raid.Document( + id=str(uuid.uuid4()), + edition_id=active_edition.id, + name=f"{doc_type.value}.pdf", + uploaded_at=datetime.datetime.now(tz=datetime.UTC).date(), + type=doc_type, + validation=DocumentValidation.accepted, + ) + db.add(doc) + docs[doc_type] = doc + + 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) + + db.add( + models_raid.RaidParticipant( + user_id=second_user.id, + edition_id=active_edition.id, + status=RaidRegistrationStatus.draft, + situation=Situation.other, + is_minor=False, + ), + ) + await db.flush() + + team = models_raid.RaidTeam( + id=str(uuid.uuid4()), + edition_id=active_edition.id, + name=f"ScholarTeam-{user.id}", + difficulty=Difficulty.sports, + meeting_place=MeetingPlace.centrale, + captain_id=user.id, + second_id=second_user.id, + ) + db.add(team) + await db.flush() + + await db.execute( + update(models_raid.RaidParticipant) + .where( + models_raid.RaidParticipant.user_id == user.id, + models_raid.RaidParticipant.edition_id == active_edition.id, + ) + .values( + status=RaidRegistrationStatus.submitted, + situation=Situation.other, # scholarship applies without student card + has_scholarship=True, + id_card_id=docs[DocumentType.idCard].id, + medical_certificate_id=docs[DocumentType.medicalCertificate].id, + raid_rules_id=docs[DocumentType.raidRules].id, + school_authorization_id=docs[DocumentType.schoolAuthorization].id, + security_file_id=security.id, + attestation_on_honour=True, + payment=True, + t_shirt_payment=True, + is_minor=False, + ), + ) + await db.commit() + + +async def test_scholarship_participant_can_be_validated(client: TestClient) -> None: + """A scholarship participant with an accepted school authorization validates.""" + user = await create_user_with_groups([]) + await _set_user_identity(user.id, "+33671000001", datetime.date(2000, 1, 1)) + token = create_api_access_token(user) + + r = client.post( + "/raid/participants", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201 + + await _setup_scholarship_participant(user) + + r = client.patch( + f"/raid/participants/{user.id}/validate", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert r.status_code == 204, r.json() + + r = client.get( + f"/raid/participants/{user.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "validated" + assert body["has_scholarship"] is True + assert body["school_authorization_id"] is not None + + +async def test_scholarship_participant_rejected_without_school_authorization( + client: TestClient, +) -> None: + """Admin validation fails while the school authorization is missing.""" + user = await create_user_with_groups([]) + await _set_user_identity(user.id, "+33671000002", datetime.date(2000, 2, 2)) + token = create_api_access_token(user) + + r = client.post( + "/raid/participants", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201 + + await _setup_scholarship_participant(user) + + async with get_TestingSessionLocal()() as db: + await db.execute( + update(models_raid.RaidParticipant) + .where( + models_raid.RaidParticipant.user_id == user.id, + models_raid.RaidParticipant.edition_id == active_edition.id, + ) + .values(school_authorization_id=None), + ) + await db.commit() + + r = client.patch( + f"/raid/participants/{user.id}/validate", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert r.status_code == 400 + assert r.json()["detail"] == "Missing school authorization" + + +async def test_school_authorization_lazy_load_does_not_break_read( + client: TestClient, +) -> None: + """Reading a participant with a school authorization must not lazy-load.""" + user = await create_user_with_groups([]) + await _set_user_identity(user.id, "+33671000003", datetime.date(2000, 3, 3)) + token = create_api_access_token(user) + + r = client.post( + "/raid/participants", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201 + + await _setup_scholarship_participant(user) + + r = client.get( + f"/raid/participants/{user.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["school_authorization_id"] is not None + assert body["school_authorization"]["validation"] == "accepted" + + +async def _reset_mocked_checkout() -> None: + """The mocked payment tool reuses a single checkout row without updating + its amount; purge it so each /raid/pay test observes its own charge.""" + async with get_TestingSessionLocal()() as db: + await db.execute( + delete(models_raid.RaidParticipantCheckout).where( + models_raid.RaidParticipantCheckout.checkout_id + == str(mocked_checkout_id), + ), + ) + await db.execute( + delete(models_payment.Checkout).where( + models_payment.Checkout.id == mocked_checkout_id, + ), + ) + await db.commit() + + +async def test_pay_endpoint_uses_scholarship_price(client: TestClient) -> None: + """POST /raid/pay charges the scholarship price for scholars.""" + user = await create_user_with_groups([]) + await _set_user_identity(user.id, "+33671000004", datetime.date(2000, 4, 4)) + token = create_api_access_token(user) + + r = client.post( + "/raid/participants", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201 + + await _setup_scholarship_participant(user) + # The helper sets payment=True; the pay endpoint needs an unpaid participant. + async with get_TestingSessionLocal()() as db: + await db.execute( + update(models_raid.RaidParticipant) + .where( + models_raid.RaidParticipant.user_id == user.id, + models_raid.RaidParticipant.edition_id == active_edition.id, + ) + .values(payment=False, t_shirt_payment=True), + ) + await db.commit() + + r = client.get( + "/raid/pay", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201, r.json() + url = r.json()["url"] + assert url + + # The mocked checkout stores the amount charged for this participant. + async with get_TestingSessionLocal()() as db: + checkout = await cruds_payment.get_checkout_by_id(mocked_checkout_id, db) + assert checkout is not None + assert checkout.name == "Inscription Raid - Tarif boursier" + assert checkout.amount == 25 + + +async def test_participant_can_set_scholarship_flag(client: TestClient) -> None: + """PATCH /participants accepts has_scholarship from the participant itself.""" + r = client.patch( + f"/raid/participants/{user_second.id}", + json={"has_scholarship": True}, + headers={"Authorization": f"Bearer {token_second}"}, + ) + assert r.status_code == 204, r.json() + + r = client.get( + f"/raid/participants/{user_second.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert r.status_code == 200 + assert r.json()["has_scholarship"] is True + + # Revoke it back so later tests are unaffected. + r = client.patch( + f"/raid/participants/{user_second.id}", + json={"has_scholarship": False}, + headers={"Authorization": f"Bearer {token_second}"}, + ) + assert r.status_code == 204 + + +async def test_scholarship_price_requires_accepted_school_authorization( + client: TestClient, +) -> None: + """Self-declared scholarship does not unlock the discounted price.""" + await _reset_mocked_checkout() + user = await create_user_with_groups([]) + await _set_user_identity(user.id, "+33671000005", datetime.date(2000, 5, 5)) + token = create_api_access_token(user) + + r = client.post( + "/raid/participants", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201 + + # Declare scholarship but upload no school authorization document. + r = client.patch( + f"/raid/participants/{user.id}", + json={"has_scholarship": True}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 204 + + r = client.get( + "/raid/pay", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201, r.json() + + async with get_TestingSessionLocal()() as db: + checkout = await cruds_payment.get_checkout_by_id(mocked_checkout_id, db) + assert checkout is not None + # No accepted document: falls back to the external price. + assert checkout.name == "Inscription Raid - Tarif externe" + assert checkout.amount == 90 + + +async def test_scholarship_price_applies_with_accepted_school_authorization( + client: TestClient, +) -> None: + """Scholar price only once the school authorization is accepted.""" + await _reset_mocked_checkout() + user = await create_user_with_groups([]) + await _set_user_identity(user.id, "+33671000006", datetime.date(2000, 6, 6)) + token = create_api_access_token(user) + + r = client.post( + "/raid/participants", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201 + + # Upload + assign a pending school authorization, declare the scholarship. + upload = client.post( + "/raid/document/schoolAuthorization", + files={"file": ("school_auth.pdf", b"blob", "application/pdf")}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert upload.status_code == 201 + + r = client.patch( + f"/raid/participants/{user.id}", + json={"has_scholarship": True}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 204 + + r = client.get( + "/raid/pay", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201 + async with get_TestingSessionLocal()() as db: + checkout = await cruds_payment.get_checkout_by_id(mocked_checkout_id, db) + assert checkout is not None + # Pending document is not enough: still the external price. + assert checkout.amount == 90 + + # The mocked payment tool does not update an existing checkout row. + await _reset_mocked_checkout() + + # Admin accepts the document: the scholar price now applies. + async with get_TestingSessionLocal()() as db: + participant = await cruds_raid.get_participant_by_user_id( + user.id, + active_edition.id, + db, + ) + assert participant is not None + assert participant.school_authorization_id is not None + await cruds_raid.update_document_validation( + participant.school_authorization_id, + DocumentValidation.accepted, + db, + ) + await db.commit() + + r = client.get( + "/raid/pay", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 201 + async with get_TestingSessionLocal()() as db: + checkout = await cruds_payment.get_checkout_by_id(mocked_checkout_id, db) + assert checkout is not None + assert checkout.name == "Inscription Raid - Tarif boursier" + assert checkout.amount == 25 + + +async def test_school_authorization_document_is_readable_by_owner( + client: TestClient, +) -> None: + """GET /raid/document/{id} resolves ownership of a school authorization.""" + doc_id = str(uuid.uuid4()) + await save_bytes_as_data( + file_bytes=b"%PDF-1.4 school authorization", + directory="raid", + filename=doc_id, + extension="pdf", + ) + doc = models_raid.Document( + id=doc_id, + edition_id=active_edition.id, + name="school_auth.pdf", + uploaded_at=datetime.datetime.now(tz=datetime.UTC).date(), + type=DocumentType.schoolAuthorization, + validation=DocumentValidation.pending, + ) + await add_object_to_db(doc) + + async with get_TestingSessionLocal()() as db: + await db.execute( + update(models_raid.RaidParticipant) + .where( + models_raid.RaidParticipant.user_id == user_captain.id, + models_raid.RaidParticipant.edition_id == active_edition.id, + ) + .values(school_authorization_id=doc_id), + ) + await db.commit() + + r = client.get( + f"/raid/document/{doc_id}", + headers={"Authorization": f"Bearer {token_captain}"}, + ) + assert r.status_code == 200 + + # Cleanup for later tests. + async with get_TestingSessionLocal()() as db: + await db.execute( + update(models_raid.RaidParticipant) + .where( + models_raid.RaidParticipant.user_id == user_captain.id, + models_raid.RaidParticipant.edition_id == active_edition.id, + ) + .values(school_authorization_id=None), + ) + await db.commit() + + # --------------------------------------------------------------------------- # Raw CRUD integration tests (edition-aware) # ---------------------------------------------------------------------------