diff --git a/app/modules/raid/coredata_raid.py b/app/modules/raid/coredata_raid.py index 19e043fa03..b2b7cfdc8d 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 @@ -22,4 +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 diff --git a/app/modules/raid/cruds_raid.py b/app/modules/raid/cruds_raid.py index 8db6ddef8a..66cde1c3b7 100644 --- a/app/modules/raid/cruds_raid.py +++ b/app/modules/raid/cruds_raid.py @@ -13,6 +13,24 @@ 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.school_authorization, + models_raid.RaidParticipant.user, +] + +TEAM_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 +] + async def create_participant( participant: schemas_raid.RaidParticipantCreate, @@ -20,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() @@ -49,18 +48,24 @@ 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) - .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) participants = await db.execute(stmt) + + # Remove security_file from the participants list to avoid including it in the response. + found_participants = participants.scalars().all() + return [ - schemas_raid.RaidParticipant.model_validate(p) - for p in participants.scalars().all() + schemas_raid.RaidParticipantRestricted.model_validate(participant) + for participant in found_participants ] @@ -146,12 +151,46 @@ async def get_team_by_participant_id( models_raid.RaidTeam.second_id == user_id, ), ) - .options(selectinload("*")), + .options( + *TEAM_DATA_TO_SELECT, + ), ) model = team.scalars().first() 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, @@ -159,11 +198,36 @@ async def get_all_teams( teams = await db.execute( select(models_raid.RaidTeam) .where(models_raid.RaidTeam.edition_id == edition_id) - .options(selectinload("*")), + .options( + *TEAM_DATA_TO_SELECT, + ), ) 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, @@ -196,7 +260,9 @@ async def get_all_validated_teams( Captain.c.status == RaidRegistrationStatus.validated, Second.c.status == RaidRegistrationStatus.validated, ) - .options(selectinload("*")) + .options( + *TEAM_DATA_TO_SELECT, + ) ) teams = await db.execute(stmt) return [schemas_raid.RaidTeam.model_validate(t) for t in teams.scalars().all()] @@ -209,12 +275,39 @@ async def get_team_by_id( team = await db.execute( select(models_raid.RaidTeam) .where(models_raid.RaidTeam.id == team_id) - .options(selectinload("*")), + .options( + *TEAM_DATA_TO_SELECT, + ), ) model = team.scalars().first() 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, @@ -372,6 +465,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() @@ -495,7 +590,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( @@ -505,12 +600,17 @@ 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(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 + return ( + schemas_raid.RaidParticipantRestricted.model_validate(model) if model else None + ) async def update_document( @@ -570,6 +670,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, @@ -590,14 +722,40 @@ async def get_participant_by_user_id( user_id: str, edition_id: UUID, db: AsyncSession, +) -> schemas_raid.RaidParticipantRestricted | None: + + 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) .where( models_raid.RaidParticipant.user_id == user_id, models_raid.RaidParticipant.edition_id == edition_id, ) - .options(selectinload("*")), + .options( + *[selectinload(data) for data in PARTICIPANT_DATA_TO_SELECT], + selectinload(models_raid.RaidParticipant.security_file), + ), ) model = participant.scalars().first() return schemas_raid.RaidParticipant.model_validate(model) if model else None @@ -758,6 +916,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 ------------------------------------------------------ @@ -907,6 +1093,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/dependencies_raid.py b/app/modules/raid/dependencies_raid.py index f3db544126..93007b11cb 100644 --- a/app/modules/raid/dependencies_raid.py +++ b/app/modules/raid/dependencies_raid.py @@ -28,8 +28,30 @@ async def get_participant_or_404( user_id: str, edition_id: UUID, db: AsyncSession = Depends(get_db), +) -> schemas_raid.RaidParticipantRestricted: + participant = await cruds_raid.get_participant_by_user_id( + user_id, + edition_id, + db, + ) + if participant is None: + raise HTTPException(status_code=404, detail="Participant not found") + return participant + + +async def get_participant_complete_or_404( + user_id: str, + edition_id: UUID, + db: AsyncSession = Depends(get_db), ) -> schemas_raid.RaidParticipant: - participant = await cruds_raid.get_participant_by_user_id(user_id, edition_id, db) + """ + 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") return participant @@ -53,7 +75,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 5a50b21fa2..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 @@ -22,6 +23,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, ) @@ -34,6 +36,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, @@ -57,11 +60,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( @@ -190,28 +195,38 @@ 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_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), +): + + return await get_participant_complete_or_404(user.id, edition.id, db) + + +@module.router.get( + "/raid/participants/{user_id}", + response_model=schemas_raid.RaidParticipantRestrictedComplete, + 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.access_raid]), + is_user_allowed_to([RaidPermissions.manage_raid]), ), edition: schemas_raid.RaidEdition = Depends(get_current_raid_edition), ): - if user_id != user.id and not await has_user_permission( - user, - RaidPermissions.manage_raid, - db, - ): - 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_complete_or_404(user_id, edition.id, db) + + return schemas_raid.RaidParticipantRestrictedComplete(**participant.model_dump()) @module.router.post( @@ -251,7 +266,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_complete_or_404(user.id, edition.id, db) @module.router.patch( @@ -269,10 +284,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", @@ -290,6 +305,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): @@ -361,11 +377,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", @@ -390,7 +406,12 @@ 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_complete_or_404( + user_id, + edition.id, + db, + ) + await check_participant_validation_consistency(participant, edition.id, db) await cruds_raid.update_participant_status( user_id, @@ -412,11 +433,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", @@ -466,6 +487,50 @@ 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_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.") + + return schemas_raid.RaidTeamComplete( + 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=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, + ) + + @module.router.get( "/raid/participants/{user_id}/team", response_model=schemas_raid.RaidTeam, @@ -508,7 +573,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( @@ -518,10 +583,29 @@ 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=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, + ) @module.router.patch( @@ -538,10 +622,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) @@ -632,6 +716,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, @@ -678,8 +763,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, @@ -740,24 +825,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, @@ -787,6 +865,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( @@ -843,6 +923,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, @@ -917,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: @@ -933,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( @@ -1112,6 +1244,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.") @@ -1141,6 +1274,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 # --------------------------------------------------------------------------- @@ -1158,8 +1334,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", @@ -1291,11 +1489,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", @@ -1332,8 +1530,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) @@ -1351,11 +1549,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)", diff --git a/app/modules/raid/models_raid.py b/app/modules/raid/models_raid.py index 4ae59c132f..b247578431 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: @@ -163,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", @@ -251,6 +263,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( @@ -274,6 +305,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/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 diff --git a/app/modules/raid/schemas_raid.py b/app/modules/raid/schemas_raid.py index 163b6bbc15..db0eb91d14 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): @@ -100,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): @@ -120,7 +124,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 @@ -130,20 +138,17 @@ 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 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 - - @computed_field # type: ignore[prop-decorator] - @property - def validation_progress(self) -> float: - return compute_participant_progress(self) + has_scholarship: bool @computed_field # type: ignore[prop-decorator] @property @@ -156,6 +161,20 @@ def number_of_validated_document(self) -> int: return count_accepted_documents(self) +class RaidParticipantRestrictedComplete(RaidParticipantRestricted): + # 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 @@ -171,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 @@ -238,15 +259,28 @@ 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 + + captain: RaidParticipantRestrictedComplete + second: RaidParticipantRestrictedComplete | None = None + + +class RaidTeamIncludingSecurityFile(RaidTeam): + captain: RaidParticipant + second: RaidParticipant | None = None + @computed_field # type: ignore[prop-decorator] @property def validation_progress(self) -> float: @@ -304,6 +338,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 @@ -349,6 +391,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): @@ -368,6 +412,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) @@ -378,6 +424,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 d2f139acac..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, @@ -60,43 +65,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) + 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( + participant_user_id, + edition_id, + db, + ) + 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( + 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( @@ -123,7 +172,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: @@ -151,6 +202,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, } @@ -165,7 +219,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, @@ -193,7 +247,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", ) @@ -231,7 +285,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", ) @@ -265,7 +319,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.") @@ -273,26 +327,39 @@ async def get_participant( def calculate_raid_payment( - participant: schemas_raid.RaidParticipant, + participant: schemas_raid.RaidParticipantRestricted, raid_prices: coredata_raid.RaidPrice, ): if ( 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" @@ -306,3 +373,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/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py index 8741f79460..e8f5843a22 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") @@ -110,6 +116,11 @@ def _check_all_documents_accepted(participant: schemas_raid.RaidParticipant) -> participant.parent_authorization, "parent authorization", ) + if participant.has_scholarship: + _check_document_accepted( + participant.school_authorization, + "school authorization", + ) def _check_document_accepted( @@ -129,7 +140,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( @@ -202,6 +213,7 @@ class _ParticipantContext: situation: Situation | None is_minor: bool + has_scholarship: bool = False @dataclass(frozen=True) @@ -247,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. @@ -259,10 +276,13 @@ 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, + has_scholarship=participant.has_scholarship, ) @@ -270,7 +290,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 +324,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 +333,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 +344,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/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 diff --git a/migrations/versions/67-raid-security-file-consent.py b/migrations/versions/67-raid-security-file-consent.py new file mode 100644 index 0000000000..df9d486e08 --- /dev/null +++ b/migrations/versions/67-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 = "320892a84fd8" +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=True), + ) + + +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 diff --git a/migrations/versions/68-raid-payment-for-volunteer.py b/migrations/versions/68-raid-payment-for-volunteer.py new file mode 100644 index 0000000000..52ce5ee524 --- /dev/null +++ b/migrations/versions/68-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/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 diff --git a/tests/modules/raid/test_pdf_generation.py b/tests/modules/raid/test_pdf_generation.py index cbbf121018..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 @@ -67,6 +69,7 @@ def _create_mock_information() -> MagicMock: info.rescue = None info.security_responsible = None info.volunteer_responsible = None + info.course_responsible = None return info @@ -141,7 +144,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( @@ -171,7 +174,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_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_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, ) diff --git a/tests/modules/raid/test_utils_raid.py b/tests/modules/raid/test_utils_raid.py index bc282b930b..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: @@ -328,7 +418,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 +437,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/raid/test_utils_raid_extended.py b/tests/modules/raid/test_utils_raid_extended.py index 5a324e73b3..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, ) @@ -145,6 +146,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()) @@ -300,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) @@ -321,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) @@ -342,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) @@ -363,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) @@ -375,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) @@ -383,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) @@ -403,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) @@ -423,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) @@ -443,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) @@ -463,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, @@ -488,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) @@ -522,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( @@ -547,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 1a01af223e..8ebbfcfb76 100644 --- a/tests/modules/test_raid.py +++ b/tests/modules/test_raid.py @@ -7,16 +7,16 @@ the participant/volunteer payloads stay small and mirror the real API shape. """ -import asyncio import datetime import uuid 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 @@ -29,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, @@ -36,6 +37,7 @@ create_groups_with_permissions, create_user_with_groups, get_TestingSessionLocal, + mocked_checkout_id, ) # --------------------------------------------------------------------------- @@ -103,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()) @@ -298,7 +301,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 @@ -413,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", @@ -442,7 +457,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 +523,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" @@ -559,6 +573,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" @@ -569,9 +584,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" @@ -608,10 +624,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"}, @@ -620,6 +638,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 # --------------------------------------------------------------------------- @@ -634,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", @@ -776,22 +821,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 +844,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", @@ -847,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) # ---------------------------------------------------------------------------