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"}} +