From 8dc705ac6ae7832f0b74de29c662c9536b693b2a Mon Sep 17 00:00:00 2001 From: Julien Cougnaud Date: Mon, 2 Dec 2024 09:39:03 +0100 Subject: [PATCH] [OS-664] Doctorate > create an admission based on a pre admission --- .pre-commit-config.yaml | 2 +- admin.py | 13 +- api/schema.py | 2 +- api/serializers/project.py | 61 +++- api/url_v1.py | 1 + api/views/project.py | 40 ++- auth/predicates/doctorate.py | 7 + auth/roles/candidate.py | 4 +- .../builder/proposition_builder.py | 46 ++- .../doctorat/preparation/commands.py | 13 +- .../domain/service/i_historique.py | 4 +- .../doctorat/preparation/dtos/proposition.py | 1 + .../preparation/repository/i_proposition.py | 1 + .../lister_propositions_candidat_service.py | 17 +- .../write/initier_proposition_service.py | 13 +- .../doctorat/preparation/builder/__init__.py | 0 .../preparation/builder/in_memory/__init__.py | 0 .../builder/in_memory/proposition_builder.py | 46 +++ .../builder/proposition_builder.py | 146 ++++++++ .../preparation/domain/service/historique.py | 16 +- .../domain/service/in_memory/historique.py | 4 +- .../doctorat/preparation/handlers.py | 2 + .../preparation/handlers_in_memory.py | 3 + .../repository/in_memory/proposition.py | 22 +- .../preparation/repository/proposition.py | 302 ++++++++++------- locale/en/LC_MESSAGES/django.po | 12 +- locale/fr_BE/LC_MESSAGES/django.po | 32 +- ...octorateadmission_related_pre_admission.py | 26 ++ models/doctorate.py | 9 + schema.yml | 90 ++++- tests/api/views/test_project.py | 194 +++++++++++ tests/api/views/test_signatures.py | 5 +- tests/api/views/test_training_choice.py | 311 +++++++++++++++++- tests/exports/test_admission_recap.py | 1 + 34 files changed, 1226 insertions(+), 220 deletions(-) create mode 100644 infrastructure/admission/doctorat/preparation/builder/__init__.py create mode 100644 infrastructure/admission/doctorat/preparation/builder/in_memory/__init__.py create mode 100644 infrastructure/admission/doctorat/preparation/builder/in_memory/proposition_builder.py create mode 100644 infrastructure/admission/doctorat/preparation/builder/proposition_builder.py create mode 100644 migrations/0237_doctorateadmission_related_pre_admission.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c314beef6..1e64d06f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,6 +54,6 @@ repos: - id: check_app_messages name: Check messages language: system - entry: bash -c '(cd .. && ./manage.py check_app_messages parcours_doctoral)' + entry: bash -c '(cd .. && ./manage.py check_app_messages admission)' always_run: true pass_filenames: false diff --git a/admin.py b/admin.py index d70239baf..47473928d 100644 --- a/admin.py +++ b/admin.py @@ -201,6 +201,7 @@ class DoctorateAdmissionAdmin(AdmissionAdminMixin): 'thesis_language', 'prerequisite_courses', 'refusal_reasons', + 'related_pre_admission', ] list_display = ['reference', 'candidate_fmt', 'doctorate', 'type', 'status', 'view_on_portal'] list_filter = ['status', 'type'] @@ -667,15 +668,15 @@ def queryset(self, request, queryset): | Q( checklist__current__financabilite__status='GEST_REUSSITE', checklist__current__financanbilite__extra__reussite='financable', - generaleducationadmission__financability_rule='' + generaleducationadmission__financability_rule='', ) | Q( checklist__current__financabilite__status='GEST_REUSSITE', - generaleducationadmission__financability_established_on__isnull=True + generaleducationadmission__financability_established_on__isnull=True, ) | Q( checklist__current__financabilite__status='GEST_REUSSITE', - generaleducationadmission__financability_established_by_id__isnull=True + generaleducationadmission__financability_established_by_id__isnull=True, ), generaleducationadmission__isnull=False, then=Value(False), @@ -758,13 +759,10 @@ def get_queryset(self, request): ) ) - @admin.display( - ordering='_noma_sent_to_digit' - ) + @admin.display(ordering='_noma_sent_to_digit') def noma_sent_to_digit(self, obj): return obj._noma_sent_to_digit - @admin.action(description='Injecter la demande dans EPC') def injecter_dans_epc(self, request, queryset): for demande in queryset.exclude( @@ -922,6 +920,7 @@ def has_add_permission(self, request) -> bool: def has_change_permission(self, request, obj=None) -> bool: return False + # ############################################################################## # Roles diff --git a/api/schema.py b/api/schema.py index 63693a17b..3908bea70 100644 --- a/api/schema.py +++ b/api/schema.py @@ -32,7 +32,7 @@ from backoffice.settings.rest_framework.fields import ActionLinksField from base.models.utils.utils import ChoiceEnum -ADMISSION_SDK_VERSION = "1.0.110" +ADMISSION_SDK_VERSION = "1.0.111" class AdmissionSchemaGenerator(SchemaGenerator): diff --git a/api/serializers/project.py b/api/serializers/project.py index e9cbca9b9..6a767e2fa 100644 --- a/api/serializers/project.py +++ b/api/serializers/project.py @@ -35,7 +35,6 @@ RelatedInstituteField, ) from admission.api.serializers.mixins import IncludedFieldsMixin -from admission.models import DoctorateAdmission, GeneralEducationAdmission from admission.ddd.admission.doctorat.preparation.commands import CompleterPropositionCommand, InitierPropositionCommand from admission.ddd.admission.doctorat.preparation.domain.model.enums import ( ChoixCommissionProximiteCDEouCLSM, @@ -49,11 +48,13 @@ DoctoratFormationDTO, PropositionDTO as DoctoratPropositionDTO, ) +from admission.ddd.admission.dtos.campus import CampusDTO from admission.ddd.admission.dtos.formation import FormationDTO from admission.ddd.admission.formation_continue.domain.model.enums import ChoixStatutPropositionContinue from admission.ddd.admission.formation_continue.dtos import PropositionDTO as FormationContinuePropositionDTO from admission.ddd.admission.formation_generale.domain.model.enums import ChoixStatutPropositionGenerale from admission.ddd.admission.formation_generale.dtos import PropositionDTO as FormationGeneralePropositionDTO +from admission.models import DoctorateAdmission, GeneralEducationAdmission from backoffice.settings.rest_framework.fields import ActionLinksField from base.utils.serializers import DTOSerializer @@ -76,6 +77,7 @@ "ContinuingEducationPropositionDTOSerializer", "PROPOSITION_ERROR_SCHEMA", "GeneralEducationPropositionIdentityWithStatusSerializer", + "DoctoratePreAdmissionSearchDTOSerializer", ] from reference.api.serializers.language import RelatedLanguageField @@ -245,6 +247,7 @@ class Meta: source = DoctoratPropositionDTO fields = [ 'uuid', + 'pre_admission_associee', 'reference', 'type_admission', 'doctorat', @@ -465,6 +468,7 @@ class Meta: source = DoctoratPropositionDTO fields = [ 'uuid', + 'pre_admission_associee', 'type_admission', 'reference', 'justification', @@ -710,7 +714,7 @@ class Meta: ) -class CompleterPropositionCommandSerializer(InitierPropositionCommandSerializer): +class CompleterPropositionCommandSerializer(DTOSerializer): documents_projet = serializers.ListField(child=serializers.CharField()) graphe_gantt = serializers.ListField(child=serializers.CharField()) proposition_programme_doctoral = serializers.ListField(child=serializers.CharField()) @@ -722,6 +726,12 @@ class CompleterPropositionCommandSerializer(InitierPropositionCommandSerializer) ) langue_redaction_these = RelatedLanguageField(required=False) institut_these = RelatedInstituteField(required=False) + commission_proximite = serializers.ChoiceField( + choices=ChoixCommissionProximiteCDEouCLSM.choices() + + ChoixCommissionProximiteCDSS.choices() + + ChoixSousDomaineSciences.choices(), + allow_blank=True, + ) type_admission = None matricule_auteur = None @@ -732,3 +742,50 @@ class Meta: class SectorDTOSerializer(serializers.Serializer): sigle = serializers.ReadOnlyField() intitule = serializers.ReadOnlyField() + + +class CampusDTOSerializer(IncludedFieldsMixin, DTOSerializer): + class Meta: + source = CampusDTO + fields = [ + 'uuid', + 'nom', + ] + + +class DoctoratSearchDTOSerializer(IncludedFieldsMixin, DTOSerializer): + campus = CampusDTOSerializer() + date_debut = None + intitule_fr = None + intitule_en = None + credits = None + + class Meta: + source = DoctoratFormationDTO + fields = [ + 'sigle', + 'code', + 'annee', + 'intitule', + 'sigle_entite_gestion', + 'campus', + ] + + +class DoctoratePreAdmissionSearchDTOSerializer(IncludedFieldsMixin, DTOSerializer): + doctorat = DoctoratSearchDTOSerializer() + # This is to prevent schema from breaking on JSONField + erreurs = None + reponses_questions_specifiques = None + elements_confirmation = None + documents_demandes = None + + class Meta: + source = DoctoratPropositionDTO + fields = [ + 'uuid', + 'reference', + 'doctorat', + 'code_secteur_formation', + 'intitule_secteur_formation', + ] diff --git a/api/url_v1.py b/api/url_v1.py index 5793c0891..c4c4321e2 100644 --- a/api/url_v1.py +++ b/api/url_v1.py @@ -93,6 +93,7 @@ def path(pattern, view, name=None): path('curriculum', views.PersonCurriculumView), # Admission-related path('propositions/doctorate', views.DoctorateTrainingChoiceAPIView), + path('propositions/doctorate/pre-admission-list', views.DoctoratePreAdmissionList), path('propositions/doctorate/', views.DoctoratePropositionView), _path('propositions/doctorate//', include(person_tabs)), path('propositions/doctorate//project', views.ProjectViewSet), diff --git a/api/views/project.py b/api/views/project.py index 8b6e4e1b0..012dead1f 100644 --- a/api/views/project.py +++ b/api/views/project.py @@ -33,12 +33,15 @@ from admission.api import serializers from admission.api.permissions import IsListingOrHasNotAlreadyCreatedPermission, IsSupervisionMember from admission.api.schema import ResponseSpecificSchema -from admission.models import DoctorateAdmission from admission.ddd.admission.doctorat.preparation.commands import ( CompleterPropositionCommand, ListerPropositionsCandidatQuery as ListerPropositionsDoctoralesCandidatQuery, ListerPropositionsSuperviseesQuery, ) +from admission.ddd.admission.doctorat.preparation.domain.model.enums import ( + ChoixTypeAdmission, + ChoixStatutPropositionDoctorale, +) from admission.ddd.admission.doctorat.preparation.domain.validator.exceptions import JustificationRequiseException from admission.ddd.admission.formation_continue.commands import ( ListerPropositionsCandidatQuery as ListerPropositionsFormationContinueCandidatQuery, @@ -46,6 +49,7 @@ from admission.ddd.admission.formation_generale.commands import ( ListerPropositionsCandidatQuery as ListerPropositionsFormationGeneraleCandidatQuery, ) +from admission.models import DoctorateAdmission from admission.utils import get_cached_admission_perm_obj from backoffice.settings.rest_framework.common_views import DisplayExceptionsByFieldNameAPIMixin from infrastructure.messages_bus import message_bus_instance @@ -56,6 +60,7 @@ "PropositionListView", "SupervisedPropositionListView", "ProjectViewSet", + "DoctoratePreAdmissionList", ] @@ -229,3 +234,36 @@ def put(self, request, *args, **kwargs): self.get_permission_object().update_detailed_status(request.user.person) serializer = serializers.PropositionIdentityDTOSerializer(instance=result) return Response(serializer.data, status=status.HTTP_200_OK) + + +class DoctoratePreAdmissionListSchema(ResponseSpecificSchema): + operation_id_base = '_doctorate_pre_admission' + serializer_mapping = { + 'GET': serializers.DoctoratePreAdmissionSearchDTOSerializer, + } + + +class DoctoratePreAdmissionList(APIPermissionRequiredMixin, DisplayExceptionsByFieldNameAPIMixin, ListAPIView): + name = "doctorate_pre_admission_list" + schema = DoctoratePreAdmissionListSchema() + pagination_class = None + filter_backends = [] + permission_classes = [IsListingOrHasNotAlreadyCreatedPermission] + + def list(self, request, **kwargs): + """List the propositions of the logged in user""" + doctorate_list = message_bus_instance.invoke( + ListerPropositionsDoctoralesCandidatQuery( + matricule_candidat=request.user.person.global_id, + type_admission=ChoixTypeAdmission.PRE_ADMISSION.name, + statut=ChoixStatutPropositionDoctorale.INSCRIPTION_AUTORISEE.name, + est_pre_admission_d_une_admission_en_cours=False, + ), + ) + + serializer = serializers.DoctoratePreAdmissionSearchDTOSerializer( + instance=doctorate_list, + many=True, + ) + + return Response(serializer.data) diff --git a/auth/predicates/doctorate.py b/auth/predicates/doctorate.py index 4ac125cc6..0be2b2114 100644 --- a/auth/predicates/doctorate.py +++ b/auth/predicates/doctorate.py @@ -65,6 +65,7 @@ def signing_in_progress(self, user: User, obj: DoctorateAdmission): def is_invited_to_complete(self, user: User, obj: DoctorateAdmission): return obj.status in STATUTS_PROPOSITION_DOCTORALE_SOUMISE_POUR_CANDIDAT + @predicate(bind=True) @predicate_failed_msg(message=_("The proposition has already been confirmed or is cancelled")) def unconfirmed_proposition(self, user: User, obj: DoctorateAdmission): @@ -214,3 +215,9 @@ def can_send_to_fac_faculty_decision(self, user: User, obj: DoctorateAdmission): isinstance(obj, DoctorateAdmission) and obj.status in STATUTS_PROPOSITION_DOCTORALE_ENVOYABLE_EN_CDD_POUR_DECISION ) + + +@predicate(bind=True) +@predicate_failed_msg(message=_("The admission must not follow a pre-admission")) +def must_not_follow_a_pre_admission(self, user: User, obj: DoctorateAdmission): + return not bool(obj.related_pre_admission_id) diff --git a/auth/roles/candidate.py b/auth/roles/candidate.py index 85e912840..7ebbc0408 100644 --- a/auth/roles/candidate.py +++ b/auth/roles/candidate.py @@ -64,7 +64,9 @@ 'change_admission_languages': common.is_admission_request_author & doctorate.unconfirmed_proposition, 'change_admission_accounting': common.is_admission_request_author & doctorate.unconfirmed_proposition, # Project tabs and supervision group edition are accessible as long as signing has not begun - 'change_admission_training_choice': common.is_admission_request_author & doctorate.in_progress, + 'change_admission_training_choice': common.is_admission_request_author + & doctorate.in_progress + & doctorate.must_not_follow_a_pre_admission, 'change_admission_project': common.is_admission_request_author & doctorate.in_progress, 'change_admission_cotutelle': common.is_admission_request_author & doctorate.in_progress & doctorate.is_admission, 'change_admission_supervision': common.is_admission_request_author & doctorate.in_progress, diff --git a/ddd/admission/doctorat/preparation/builder/proposition_builder.py b/ddd/admission/doctorat/preparation/builder/proposition_builder.py index 0c13d0111..bdadbf3f9 100644 --- a/ddd/admission/doctorat/preparation/builder/proposition_builder.py +++ b/ddd/admission/doctorat/preparation/builder/proposition_builder.py @@ -23,6 +23,7 @@ # see http://www.gnu.org/licenses/. # ############################################################################## +from abc import abstractmethod from typing import Optional, Union from admission.ddd.admission.doctorat.preparation.builder.proposition_identity_builder import PropositionIdentityBuilder @@ -39,15 +40,16 @@ from admission.ddd.admission.doctorat.preparation.domain.model.proposition import ( Proposition, ) +from admission.ddd.admission.doctorat.preparation.domain.model.proposition import PropositionIdentity +from admission.ddd.admission.doctorat.preparation.domain.service.i_doctorat import IDoctoratTranslator from admission.ddd.admission.doctorat.preparation.domain.validator.validator_by_business_action import ( InitierPropositionValidatorList, ) from admission.ddd.admission.doctorat.preparation.repository.i_proposition import IPropositionRepository -from admission.ddd.admission.domain.model.formation import FormationIdentity from osis_common.ddd import interface -class PropositionBuilder(interface.RootEntityBuilder): +class IPropositionBuilder(interface.RootEntityBuilder): @classmethod def build_from_repository_dto(cls, dto_object: 'interface.DTO') -> 'Proposition': raise NotImplementedError @@ -60,9 +62,30 @@ def build_from_command(cls, cmd: 'InitierPropositionCommand'): # type: ignore[o def initier_proposition( cls, cmd: 'InitierPropositionCommand', - doctorat: 'DoctoratFormation', + doctorat_translator: 'IDoctoratTranslator', proposition_repository: 'IPropositionRepository', - ) -> 'Proposition': + ) -> 'PropositionIdentity ': + if cmd.pre_admission_associee: + return cls.initier_nouvelle_proposition_attachee_a_pre_admission( + cmd, + doctorat_translator, + proposition_repository, + ) + else: + return cls.initier_nouvelle_proposition_non_attachee_a_pre_admission( + cmd, + doctorat_translator, + proposition_repository, + ) + + @classmethod + def initier_nouvelle_proposition_non_attachee_a_pre_admission( + cls, + cmd: 'InitierPropositionCommand', + doctorat_translator: 'IDoctoratTranslator', + proposition_repository: 'IPropositionRepository', + ) -> 'PropositionIdentity': + doctorat = doctorat_translator.get(cmd.sigle_formation, cmd.annee_formation) InitierPropositionValidatorList( type_admission=cmd.type_admission, justification=cmd.justification, @@ -79,7 +102,7 @@ def initier_proposition( elif cmd.commission_proximite and cmd.commission_proximite in ChoixSousDomaineSciences.get_names(): commission_proximite = ChoixSousDomaineSciences[cmd.commission_proximite] reference = proposition_repository.recuperer_reference_suivante() - return Proposition( + proposition = Proposition( entity_id=PropositionIdentityBuilder.build(), reference=reference, statut=ChoixStatutPropositionDoctorale.EN_BROUILLON, @@ -91,3 +114,16 @@ def initier_proposition( projet=projet_non_rempli, auteur_derniere_modification=cmd.matricule_candidat, ) + proposition_repository.save(proposition) + + return proposition.entity_id + + @classmethod + @abstractmethod + def initier_nouvelle_proposition_attachee_a_pre_admission( + cls, + cmd: 'InitierPropositionCommand', + doctorat_translator: 'IDoctoratTranslator', + proposition_repository: 'IPropositionRepository', + ) -> 'PropositionIdentity': + raise NotImplementedError diff --git a/ddd/admission/doctorat/preparation/commands.py b/ddd/admission/doctorat/preparation/commands.py index 83a6d86d3..9d7161b20 100644 --- a/ddd/admission/doctorat/preparation/commands.py +++ b/ddd/admission/doctorat/preparation/commands.py @@ -42,9 +42,10 @@ @attr.dataclass(frozen=True, slots=True) class InitierPropositionCommand(interface.CommandRequest): type_admission: str - sigle_formation: str - annee_formation: int matricule_candidat: str + sigle_formation: Optional[str] = '' + annee_formation: Optional[int] = None + pre_admission_associee: Optional[str] = '' justification: Optional[str] = '' commission_proximite: Optional[str] = '' @@ -237,6 +238,9 @@ class DefinirCotutelleCommand(interface.CommandRequest): @attr.dataclass(frozen=True, slots=True) class ListerPropositionsCandidatQuery(interface.QueryRequest): matricule_candidat: str + type_admission: Optional[str] = '' + statut: Optional[str] = '' + est_pre_admission_d_une_admission_en_cours: Optional[bool] = None @attr.dataclass(frozen=True, slots=True) @@ -762,3 +766,8 @@ class VerifierCurriculumApresSoumissionQuery(interface.QueryRequest): @attr.dataclass(frozen=True, slots=True) class RecupererAdmissionDoctoratQuery(interface.QueryRequest): doctorat_uuid: str + + +@attr.dataclass(frozen=True, slots=True) +class ListerPreAdmissionsCandidatQuery(interface.QueryRequest): + matricule_candidat: str diff --git a/ddd/admission/doctorat/preparation/domain/service/i_historique.py b/ddd/admission/doctorat/preparation/domain/service/i_historique.py index 1a05e54dc..fbcdcafcb 100644 --- a/ddd/admission/doctorat/preparation/domain/service/i_historique.py +++ b/ddd/admission/doctorat/preparation/domain/service/i_historique.py @@ -31,7 +31,7 @@ GroupeDeSupervision, SignataireIdentity, ) -from admission.ddd.admission.doctorat.preparation.domain.model.proposition import Proposition +from admission.ddd.admission.doctorat.preparation.domain.model.proposition import Proposition, PropositionIdentity from admission.ddd.admission.doctorat.preparation.dtos import AvisDTO from admission.ddd.admission.domain.model.enums.authentification import EtatAuthentificationParcours from ddd.logic.shared_kernel.personne_connue_ucl.dtos import PersonneConnueUclDTO @@ -40,7 +40,7 @@ class IHistorique(interface.DomainService): @classmethod - def historiser_initiation(cls, proposition: Proposition): + def historiser_initiation(cls, proposition_identity: PropositionIdentity, matricule_auteur: str): raise NotImplementedError @classmethod diff --git a/ddd/admission/doctorat/preparation/dtos/proposition.py b/ddd/admission/doctorat/preparation/dtos/proposition.py index 96f5f020e..a8a93c3d2 100644 --- a/ddd/admission/doctorat/preparation/dtos/proposition.py +++ b/ddd/admission/doctorat/preparation/dtos/proposition.py @@ -49,6 +49,7 @@ class PropositionDTO(interface.DTO): uuid: str type_admission: str reference: str + pre_admission_associee: Optional[str] justification: Optional[str] doctorat: DoctoratFormationDTO annee_calculee: Optional[int] diff --git a/ddd/admission/doctorat/preparation/repository/i_proposition.py b/ddd/admission/doctorat/preparation/repository/i_proposition.py index 6bb052dd9..5469ae59c 100644 --- a/ddd/admission/doctorat/preparation/repository/i_proposition.py +++ b/ddd/admission/doctorat/preparation/repository/i_proposition.py @@ -75,6 +75,7 @@ def search_dto( matricule_promoteur: Optional[str] = '', cotutelle: Optional[bool] = None, entity_ids: Optional[List['PropositionIdentity']] = None, + est_pre_admission_d_une_admission_en_cours: Optional[bool] = None, ) -> List['PropositionDTO']: raise NotImplementedError diff --git a/ddd/admission/doctorat/preparation/use_case/read/lister_propositions_candidat_service.py b/ddd/admission/doctorat/preparation/use_case/read/lister_propositions_candidat_service.py index 5a1aab9a4..48371164c 100644 --- a/ddd/admission/doctorat/preparation/use_case/read/lister_propositions_candidat_service.py +++ b/ddd/admission/doctorat/preparation/use_case/read/lister_propositions_candidat_service.py @@ -6,7 +6,7 @@ # The core business involves the administration of students, teachers, # courses, programs and so on. # -# Copyright (C) 2015-2021 Université catholique de Louvain (http://www.uclouvain.be) +# Copyright (C) 2015-2024 Université catholique de Louvain (http://www.uclouvain.be) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -25,13 +25,22 @@ # ############################################################################## from typing import List -from admission.ddd.admission.doctorat.preparation.commands import ListerPropositionsCandidatQuery +from admission.ddd.admission.doctorat.preparation.commands import ( + ListerPropositionsCandidatQuery, +) from admission.ddd.admission.doctorat.preparation.dtos import PropositionDTO -from admission.ddd.admission.doctorat.preparation.repository.i_proposition import IPropositionRepository +from admission.ddd.admission.doctorat.preparation.repository.i_proposition import ( + IPropositionRepository, +) def lister_propositions_candidat( cmd: 'ListerPropositionsCandidatQuery', proposition_repository: 'IPropositionRepository', ) -> List['PropositionDTO']: - return proposition_repository.search_dto(matricule_candidat=cmd.matricule_candidat) + return proposition_repository.search_dto( + matricule_candidat=cmd.matricule_candidat, + type=cmd.type_admission, + etat=cmd.statut, + est_pre_admission_d_une_admission_en_cours=cmd.est_pre_admission_d_une_admission_en_cours, + ) diff --git a/ddd/admission/doctorat/preparation/use_case/write/initier_proposition_service.py b/ddd/admission/doctorat/preparation/use_case/write/initier_proposition_service.py index e776e2802..8b2806795 100644 --- a/ddd/admission/doctorat/preparation/use_case/write/initier_proposition_service.py +++ b/ddd/admission/doctorat/preparation/use_case/write/initier_proposition_service.py @@ -23,7 +23,7 @@ # see http://www.gnu.org/licenses/. # ############################################################################## -from admission.ddd.admission.doctorat.preparation.builder.proposition_builder import PropositionBuilder +from admission.ddd.admission.doctorat.preparation.builder.proposition_builder import IPropositionBuilder from admission.ddd.admission.doctorat.preparation.commands import InitierPropositionCommand from admission.ddd.admission.doctorat.preparation.domain.model.proposition import PropositionIdentity from admission.ddd.admission.doctorat.preparation.domain.service.i_doctorat import IDoctoratTranslator @@ -38,20 +38,19 @@ def initier_proposition( doctorat_translator: 'IDoctoratTranslator', historique: 'IHistorique', maximum_propositions_service: 'IMaximumPropositionsAutorisees', + proposition_builder: 'IPropositionBuilder', ) -> 'PropositionIdentity': # GIVEN - doctorat = doctorat_translator.get(cmd.sigle_formation, cmd.annee_formation) maximum_propositions_service.verifier_nombre_propositions_en_cours(cmd.matricule_candidat) # WHEN - proposition = PropositionBuilder().initier_proposition( + proposition_identity = proposition_builder.initier_proposition( cmd, - doctorat, + doctorat_translator, proposition_repository, ) # THEN - proposition_repository.save(proposition) - historique.historiser_initiation(proposition) + historique.historiser_initiation(proposition_identity, cmd.matricule_candidat) - return proposition.entity_id + return proposition_identity diff --git a/infrastructure/admission/doctorat/preparation/builder/__init__.py b/infrastructure/admission/doctorat/preparation/builder/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/infrastructure/admission/doctorat/preparation/builder/in_memory/__init__.py b/infrastructure/admission/doctorat/preparation/builder/in_memory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/infrastructure/admission/doctorat/preparation/builder/in_memory/proposition_builder.py b/infrastructure/admission/doctorat/preparation/builder/in_memory/proposition_builder.py new file mode 100644 index 000000000..9328b9def --- /dev/null +++ b/infrastructure/admission/doctorat/preparation/builder/in_memory/proposition_builder.py @@ -0,0 +1,46 @@ +# ############################################################################## +# +# OSIS stands for Open Student Information System. It's an application +# designed to manage the core business of higher education institutions, +# such as universities, faculties, institutes and professional schools. +# The core business involves the administration of students, teachers, +# courses, programs and so on. +# +# Copyright (C) 2015-2024 Université catholique de Louvain (http://www.uclouvain.be) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# A copy of this license - GNU General Public License - is available +# at the root of the source code of this program. If not, +# see http://www.gnu.org/licenses/. +# +# ############################################################################## + +from admission.ddd.admission.doctorat.preparation.builder.proposition_builder import IPropositionBuilder +from admission.ddd.admission.doctorat.preparation.commands import InitierPropositionCommand +from admission.ddd.admission.doctorat.preparation.domain.model.proposition import PropositionIdentity +from admission.ddd.admission.doctorat.preparation.domain.service.i_doctorat import IDoctoratTranslator +from admission.ddd.admission.doctorat.preparation.repository.i_proposition import IPropositionRepository + + +class PropositionBuilderInMemory(IPropositionBuilder): + @classmethod + def initier_nouvelle_proposition_attachee_a_pre_admission( + cls, + cmd: 'InitierPropositionCommand', + doctorat_translator: 'IDoctoratTranslator', + proposition_repository: 'IPropositionRepository', + ) -> PropositionIdentity: + return cls.initier_nouvelle_proposition_non_attachee_a_pre_admission( + cmd=cmd, + doctorat_translator=doctorat_translator, + proposition_repository=proposition_repository, + ) diff --git a/infrastructure/admission/doctorat/preparation/builder/proposition_builder.py b/infrastructure/admission/doctorat/preparation/builder/proposition_builder.py new file mode 100644 index 000000000..42d8f4c05 --- /dev/null +++ b/infrastructure/admission/doctorat/preparation/builder/proposition_builder.py @@ -0,0 +1,146 @@ +# ############################################################################## +# +# OSIS stands for Open Student Information System. It's an application +# designed to manage the core business of higher education institutions, +# such as universities, faculties, institutes and professional schools. +# The core business involves the administration of students, teachers, +# courses, programs and so on. +# +# Copyright (C) 2015-2024 Université catholique de Louvain (http://www.uclouvain.be) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# A copy of this license - GNU General Public License - is available +# at the root of the source code of this program. If not, +# see http://www.gnu.org/licenses/. +# +# ############################################################################## + +from django.db import transaction +from osis_signature.enums import SignatureState +from osis_signature.models import Process, StateHistory + +from admission.ddd.admission.doctorat.preparation.builder.proposition_builder import IPropositionBuilder +from admission.ddd.admission.doctorat.preparation.builder.proposition_identity_builder import PropositionIdentityBuilder +from admission.ddd.admission.doctorat.preparation.commands import InitierPropositionCommand +from admission.ddd.admission.doctorat.preparation.domain.model.enums import ( + ChoixStatutPropositionDoctorale, +) +from admission.ddd.admission.doctorat.preparation.domain.model.proposition import PropositionIdentity +from admission.ddd.admission.doctorat.preparation.domain.service.i_doctorat import IDoctoratTranslator +from admission.ddd.admission.doctorat.preparation.repository.i_proposition import IPropositionRepository +from admission.models import DoctorateAdmission, SupervisionActor, Accounting +from admission.utils import copy_documents + + +class PropositionBuilder(IPropositionBuilder): + @classmethod + @transaction.atomic + def initier_nouvelle_proposition_attachee_a_pre_admission( + cls, + cmd: 'InitierPropositionCommand', + doctorat_translator: 'IDoctoratTranslator', + proposition_repository: 'IPropositionRepository', + ) -> PropositionIdentity: + new_reference = proposition_repository.recuperer_reference_suivante() + + pre_admission = DoctorateAdmission.objects.get( + uuid=cmd.pre_admission_associee, + candidate__global_id=cmd.matricule_candidat, + ) + + new_admission = DoctorateAdmission( + uuid=PropositionIdentityBuilder.build().uuid, + related_pre_admission=pre_admission, + reference=new_reference, + type=cmd.type_admission, + status=ChoixStatutPropositionDoctorale.EN_BROUILLON.name, + training_id=pre_admission.training_id, + comment=cmd.justification, + candidate_id=pre_admission.candidate_id, + proximity_commission=pre_admission.proximity_commission, + financing_type=pre_admission.financing_type, + financing_work_contract=pre_admission.financing_work_contract, + financing_eft=pre_admission.financing_eft, + international_scholarship_id=pre_admission.international_scholarship_id, + other_international_scholarship=pre_admission.other_international_scholarship, + scholarship_start_date=pre_admission.scholarship_start_date, + scholarship_end_date=pre_admission.scholarship_end_date, + scholarship_proof=pre_admission.scholarship_proof, + planned_duration=pre_admission.planned_duration, + dedicated_time=pre_admission.dedicated_time, + is_fnrs_fria_fresh_csc_linked=pre_admission.is_fnrs_fria_fresh_csc_linked, + financing_comment=pre_admission.financing_comment, + project_title=pre_admission.project_title, + project_abstract=pre_admission.project_abstract, + thesis_language_id=pre_admission.thesis_language_id, + thesis_institute_id=pre_admission.thesis_institute_id, + thesis_location=pre_admission.thesis_location, + phd_alread_started=pre_admission.phd_alread_started, + phd_alread_started_institute=pre_admission.phd_alread_started_institute, + work_start_date=pre_admission.work_start_date, + project_document=pre_admission.project_document, + gantt_graph=pre_admission.gantt_graph, + program_proposition=pre_admission.program_proposition, + additional_training_project=pre_admission.additional_training_project, + recommendation_letters=pre_admission.recommendation_letters, + phd_already_done=pre_admission.phd_already_done, + phd_already_done_institution=pre_admission.phd_already_done_institution, + phd_already_done_thesis_domain=pre_admission.phd_already_done_thesis_domain, + phd_already_done_defense_date=pre_admission.phd_already_done_defense_date, + phd_already_done_no_defense_reason=pre_admission.phd_already_done_no_defense_reason, + curriculum=pre_admission.curriculum, + ) + + # Duplicate the documents + copy_documents([new_admission]) + + # Duplicate the supervision group of the pre-admission + new_supervision_group = cls._duplicate_supervision_group(pre_admission) + new_admission.supervision_group = new_supervision_group + + new_admission.save() + + Accounting.objects.create(admission=new_admission) + + return PropositionIdentityBuilder.build_from_uuid(uuid=str(new_admission.uuid)) + + @classmethod + def _duplicate_supervision_group(cls, admission: DoctorateAdmission) -> Process: + process = Process.objects.create() + states = [] + for admission_actor in SupervisionActor.objects.filter(process_id=admission.supervision_group_id): + person_kwargs = ( + {'person_id': admission_actor.person_id} + if admission_actor.person_id + else { + 'first_name': admission_actor.first_name, + 'last_name': admission_actor.last_name, + 'email': admission_actor.email, + 'institute': admission_actor.institute, + 'city': admission_actor.city, + 'country_id': admission_actor.country_id, + 'language': admission_actor.language, + } + ) + + actor = SupervisionActor.objects.create( + process=process, + type=admission_actor.type, + is_doctor=admission_actor.is_doctor, + is_reference_promoter=admission_actor.is_reference_promoter, + **person_kwargs, + ) + states.append(StateHistory(actor=actor, state=SignatureState.NOT_INVITED.name)) + + StateHistory.objects.bulk_create(states) + + return process diff --git a/infrastructure/admission/doctorat/preparation/domain/service/historique.py b/infrastructure/admission/doctorat/preparation/domain/service/historique.py index 0f1a367c1..238679331 100644 --- a/infrastructure/admission/doctorat/preparation/domain/service/historique.py +++ b/infrastructure/admission/doctorat/preparation/domain/service/historique.py @@ -35,7 +35,7 @@ GroupeDeSupervision, SignataireIdentity, ) -from admission.ddd.admission.doctorat.preparation.domain.model.proposition import Proposition +from admission.ddd.admission.doctorat.preparation.domain.model.proposition import Proposition, PropositionIdentity from admission.ddd.admission.doctorat.preparation.domain.service.i_historique import IHistorique from admission.ddd.admission.doctorat.preparation.dtos import AvisDTO from admission.infrastructure.admission.doctorat.preparation.domain.service.membre_CA import MembreCATranslator @@ -56,10 +56,10 @@ def get_signataire(cls, signataire_id): return MembreCATranslator.get_dto(signataire_id) @classmethod - def historiser_initiation(cls, proposition: Proposition): - candidat = PersonneConnueUclTranslator().get(proposition.matricule_candidat) + def historiser_initiation(cls, proposition_identity: PropositionIdentity, matricule_auteur: str): + candidat = PersonneConnueUclTranslator().get(matricule_auteur) add_history_entry( - proposition.entity_id.uuid, + proposition_identity.uuid, "La proposition a été initiée.", "The proposition has been initialized.", "{candidat.prenom} {candidat.nom}".format(candidat=candidat), @@ -110,9 +110,11 @@ def historiser_avis( signataire=signataire, action="refusé" if avis.motif_refus else "aprouvé", via_pdf="via PDF " if avis.pdf else "", - role="promoteur" - if isinstance(signataire_id, PromoteurIdentity) - else "membre du comité d'accompagnement", + role=( + "promoteur" + if isinstance(signataire_id, PromoteurIdentity) + else "membre du comité d'accompagnement" + ), ) ) details = [] diff --git a/infrastructure/admission/doctorat/preparation/domain/service/in_memory/historique.py b/infrastructure/admission/doctorat/preparation/domain/service/in_memory/historique.py index 80c928fc8..630ecc5ce 100644 --- a/infrastructure/admission/doctorat/preparation/domain/service/in_memory/historique.py +++ b/infrastructure/admission/doctorat/preparation/domain/service/in_memory/historique.py @@ -31,7 +31,7 @@ GroupeDeSupervision, SignataireIdentity, ) -from admission.ddd.admission.doctorat.preparation.domain.model.proposition import Proposition +from admission.ddd.admission.doctorat.preparation.domain.model.proposition import Proposition, PropositionIdentity from admission.ddd.admission.doctorat.preparation.domain.service.i_historique import IHistorique from admission.ddd.admission.doctorat.preparation.dtos import AvisDTO from ddd.logic.shared_kernel.personne_connue_ucl.dtos import PersonneConnueUclDTO @@ -39,7 +39,7 @@ class HistoriqueInMemory(IHistorique): @classmethod - def historiser_initiation(cls, proposition: Proposition): + def historiser_initiation(cls, proposition_identity: PropositionIdentity, matricule_auteur: str): pass @classmethod diff --git a/infrastructure/admission/doctorat/preparation/handlers.py b/infrastructure/admission/doctorat/preparation/handlers.py index beb29540e..d45ba9ecb 100644 --- a/infrastructure/admission/doctorat/preparation/handlers.py +++ b/infrastructure/admission/doctorat/preparation/handlers.py @@ -52,6 +52,7 @@ from infrastructure.shared_kernel.campus.repository.uclouvain_campus import UclouvainCampusRepository from infrastructure.shared_kernel.personne_connue_ucl.personne_connue_ucl import PersonneConnueUclTranslator from infrastructure.shared_kernel.profil.domain.service.parcours_interne import ExperienceParcoursInterneTranslator +from .builder.proposition_builder import PropositionBuilder from .domain.service.comptabilite import ComptabiliteTranslator from .domain.service.doctorat import DoctoratTranslator from .domain.service.historique import Historique @@ -83,6 +84,7 @@ doctorat_translator=DoctoratTranslator(), historique=Historique(), maximum_propositions_service=MaximumPropositionsAutorisees(), + proposition_builder=PropositionBuilder(), ), CompleterPropositionCommand: lambda msg_bus, cmd: completer_proposition( cmd, diff --git a/infrastructure/admission/doctorat/preparation/handlers_in_memory.py b/infrastructure/admission/doctorat/preparation/handlers_in_memory.py index e493b80da..f70d9c143 100644 --- a/infrastructure/admission/doctorat/preparation/handlers_in_memory.py +++ b/infrastructure/admission/doctorat/preparation/handlers_in_memory.py @@ -57,6 +57,7 @@ from infrastructure.shared_kernel.profil.domain.service.in_memory.parcours_interne import ( ExperienceParcoursInterneInMemoryTranslator, ) +from .builder.in_memory.proposition_builder import PropositionBuilderInMemory from .domain.service.in_memory.comptabilite import ComptabiliteInMemoryTranslator from .domain.service.in_memory.doctorat import DoctoratInMemoryTranslator from .domain.service.in_memory.historique import HistoriqueInMemory @@ -114,6 +115,7 @@ _experience_parcours_interne_translator = ExperienceParcoursInterneInMemoryTranslator() _digit_repository = DigitInMemoryRepository() _financabilite_fetcher = FinancabiliteInMemoryFetcher() +_proposition_builder = PropositionBuilderInMemory() COMMAND_HANDLERS = { @@ -123,6 +125,7 @@ doctorat_translator=_doctorat_translator, historique=_historique, maximum_propositions_service=_maximum_propositions_autorisees, + proposition_builder=_proposition_builder, ), CompleterPropositionCommand: lambda msg_bus, cmd: completer_proposition( cmd, diff --git a/infrastructure/admission/doctorat/preparation/repository/in_memory/proposition.py b/infrastructure/admission/doctorat/preparation/repository/in_memory/proposition.py index a50a876d8..895e69585 100644 --- a/infrastructure/admission/doctorat/preparation/repository/in_memory/proposition.py +++ b/infrastructure/admission/doctorat/preparation/repository/in_memory/proposition.py @@ -185,6 +185,7 @@ def search_dto( matricule_promoteur: Optional[str] = '', cotutelle: Optional[bool] = None, entity_ids: Optional[List['PropositionIdentity']] = None, + est_pre_admission_d_une_admission_en_cours: Optional[bool] = None, ) -> List['PropositionDTO']: returned = cls.entities if matricule_candidat: @@ -295,19 +296,21 @@ def _load_dto(cls, proposition: 'Proposition'): documents_demandes=proposition.documents_demandes, documents_libres_fac_uclouvain=cls.documents_libres_fac_uclouvain.get(proposition.entity_id.uuid, []), documents_libres_sic_uclouvain=cls.documents_libres_sic_uclouvain.get(proposition.entity_id.uuid, []), - financabilite_regle_calcule=proposition.financabilite_regle_calcule.name - if proposition.financabilite_regle_calcule - else '', - financabilite_regle_calcule_situation=proposition.financabilite_regle_calcule_situation.name - if proposition.financabilite_regle_calcule_situation - else '', + financabilite_regle_calcule=( + proposition.financabilite_regle_calcule.name if proposition.financabilite_regle_calcule else '' + ), + financabilite_regle_calcule_situation=( + proposition.financabilite_regle_calcule_situation.name + if proposition.financabilite_regle_calcule_situation + else '' + ), financabilite_regle_calcule_le=proposition.financabilite_regle_calcule_le, financabilite_regle=proposition.financabilite_regle.name if proposition.financabilite_regle else '', financabilite_etabli_par=proposition.financabilite_etabli_par, financabilite_etabli_le=proposition.financabilite_etabli_le, - financabilite_derogation_statut=proposition.financabilite_derogation_statut.name - if proposition.financabilite_derogation_statut - else '', + financabilite_derogation_statut=( + proposition.financabilite_derogation_statut.name if proposition.financabilite_derogation_statut else '' + ), financabilite_derogation_premiere_notification_le=( proposition.financabilite_derogation_premiere_notification_le ), @@ -326,6 +329,7 @@ def _load_dto(cls, proposition: 'Proposition'): doit_fournir_visa_etudes=proposition.doit_fournir_visa_etudes, visa_etudes_d=proposition.visa_etudes_d, certificat_autorisation_signe=proposition.certificat_autorisation_signe, + pre_admission_associee='', ) @classmethod diff --git a/infrastructure/admission/doctorat/preparation/repository/proposition.py b/infrastructure/admission/doctorat/preparation/repository/proposition.py index aec57776b..4184759d9 100644 --- a/infrastructure/admission/doctorat/preparation/repository/proposition.py +++ b/infrastructure/admission/doctorat/preparation/repository/proposition.py @@ -29,14 +29,12 @@ import attrs from django.conf import settings -from django.db.models import OuterRef, Subquery +from django.db.models import OuterRef, Subquery, Exists from django.utils import timezone from django.utils.safestring import mark_safe from django.utils.translation import get_language, pgettext from admission.auth.roles.candidate import Candidate -from admission.models import Accounting, DoctorateAdmission -from admission.models.doctorate import PropositionProxy from admission.ddd.admission.doctorat.preparation.builder.proposition_identity_builder import PropositionIdentityBuilder from admission.ddd.admission.doctorat.preparation.domain.model._detail_projet import ( DetailProjet, @@ -110,6 +108,8 @@ from admission.infrastructure.admission.domain.service.bourse import BourseTranslator from admission.infrastructure.admission.repository.proposition import GlobalPropositionRepository from admission.infrastructure.utils import dto_to_dict +from admission.models import Accounting, DoctorateAdmission +from admission.models.doctorate import PropositionProxy from base.models.academic_year import AcademicYear from base.models.education_group_year import EducationGroupYear from base.models.entity_version import EntityVersion @@ -167,9 +167,11 @@ def _instantiate_admission(admission: 'DoctorateAdmission') -> 'Proposition': type=ChoixTypeFinancement[admission.financing_type] if admission.financing_type else None, type_contrat_travail=admission.financing_work_contract, eft=admission.financing_eft, - bourse_recherche=BourseIdentity(uuid=str(admission.international_scholarship_id)) - if admission.international_scholarship_id - else None, + bourse_recherche=( + BourseIdentity(uuid=str(admission.international_scholarship_id)) + if admission.international_scholarship_id + else None + ), autre_bourse_recherche=admission.other_international_scholarship, bourse_date_debut=admission.scholarship_start_date, bourse_date_fin=admission.scholarship_end_date, @@ -196,30 +198,34 @@ def _instantiate_admission(admission: 'DoctorateAdmission') -> 'Proposition': fiche_archive_signatures_envoyees=admission.archived_record_signatures_sent, auteur_derniere_modification=admission.last_update_author.global_id if admission.last_update_author else '', documents_demandes=admission.requested_documents, - profil_soumis_candidat=ProfilCandidat.from_dict(admission.submitted_profile) - if admission.submitted_profile - else None, + profil_soumis_candidat=( + ProfilCandidat.from_dict(admission.submitted_profile) if admission.submitted_profile else None + ), checklist_initiale=checklist_initiale and StatutsChecklistDoctorale.from_dict(checklist_initiale), checklist_actuelle=checklist_actuelle and StatutsChecklistDoctorale.from_dict(checklist_actuelle), motifs_refus=[MotifRefusIdentity(uuid=motif.uuid) for motif in admission.refusal_reasons.all()], autres_motifs_refus=admission.other_refusal_reasons, - financabilite_regle_calcule=EtatFinancabilite[admission.financability_computed_rule] - if admission.financability_computed_rule - else None, - financabilite_regle_calcule_situation=SituationFinancabilite[admission.financability_computed_rule_situation] - if admission.financability_computed_rule_situation - else None, + financabilite_regle_calcule=( + EtatFinancabilite[admission.financability_computed_rule] if admission.financability_computed_rule else None + ), + financabilite_regle_calcule_situation=( + SituationFinancabilite[admission.financability_computed_rule_situation] + if admission.financability_computed_rule_situation + else None + ), financabilite_regle_calcule_le=admission.financability_computed_rule_on, - financabilite_regle=SituationFinancabilite[admission.financability_rule] - if admission.financability_rule - else None, - financabilite_etabli_par=admission.financability_established_by.global_id - if admission.financability_established_by - else None, + financabilite_regle=( + SituationFinancabilite[admission.financability_rule] if admission.financability_rule else None + ), + financabilite_etabli_par=( + admission.financability_established_by.global_id if admission.financability_established_by else None + ), financabilite_etabli_le=admission.financability_established_on, - financabilite_derogation_statut=DerogationFinancement[admission.financability_dispensation_status] - if admission.financability_dispensation_status - else None, + financabilite_derogation_statut=( + DerogationFinancement[admission.financability_dispensation_status] + if admission.financability_dispensation_status + else None + ), financabilite_derogation_premiere_notification_le=admission.financability_dispensation_first_notification_on, financabilite_derogation_premiere_notification_par=( admission.financability_dispensation_first_notification_by.global_id @@ -248,30 +254,38 @@ def _instantiate_admission(admission: 'DoctorateAdmission') -> 'Proposition': condition_acces=ConditionAcces[admission.admission_requirement] if admission.admission_requirement else None, millesime_condition_acces=admission.admission_requirement_year and admission.admission_requirement_year.year, information_a_propos_de_la_restriction=admission.foreign_access_title_equivalency_restriction_about, - type_equivalence_titre_acces=TypeEquivalenceTitreAcces[admission.foreign_access_title_equivalency_type] - if admission.foreign_access_title_equivalency_type - else None, - statut_equivalence_titre_acces=StatutEquivalenceTitreAcces[admission.foreign_access_title_equivalency_status] - if admission.foreign_access_title_equivalency_status - else None, - etat_equivalence_titre_acces=EtatEquivalenceTitreAcces[admission.foreign_access_title_equivalency_state] - if admission.foreign_access_title_equivalency_state - else None, + type_equivalence_titre_acces=( + TypeEquivalenceTitreAcces[admission.foreign_access_title_equivalency_type] + if admission.foreign_access_title_equivalency_type + else None + ), + statut_equivalence_titre_acces=( + StatutEquivalenceTitreAcces[admission.foreign_access_title_equivalency_status] + if admission.foreign_access_title_equivalency_status + else None + ), + etat_equivalence_titre_acces=( + EtatEquivalenceTitreAcces[admission.foreign_access_title_equivalency_state] + if admission.foreign_access_title_equivalency_state + else None + ), date_prise_effet_equivalence_titre_acces=admission.foreign_access_title_equivalency_effective_date, - besoin_de_derogation=BesoinDeDerogation[admission.dispensation_needed] - if admission.dispensation_needed - else None, - droits_inscription_montant=DroitsInscriptionMontant[admission.tuition_fees_amount] - if admission.tuition_fees_amount - else None, + besoin_de_derogation=( + BesoinDeDerogation[admission.dispensation_needed] if admission.dispensation_needed else None + ), + droits_inscription_montant=( + DroitsInscriptionMontant[admission.tuition_fees_amount] if admission.tuition_fees_amount else None + ), droits_inscription_montant_autre=admission.tuition_fees_amount_other, - dispense_ou_droits_majores=DispenseOuDroitsMajores[admission.tuition_fees_dispensation] - if admission.tuition_fees_dispensation - else None, + dispense_ou_droits_majores=( + DispenseOuDroitsMajores[admission.tuition_fees_dispensation] + if admission.tuition_fees_dispensation + else None + ), est_mobilite=admission.is_mobility, - nombre_de_mois_de_mobilite=MobiliteNombreDeMois[admission.mobility_months_amount] - if admission.mobility_months_amount - else None, + nombre_de_mois_de_mobilite=( + MobiliteNombreDeMois[admission.mobility_months_amount] if admission.mobility_months_amount else None + ), doit_se_presenter_en_sic=admission.must_report_to_sic, communication_au_candidat=admission.communication_to_the_candidate, doit_fournir_visa_etudes=admission.must_provide_student_visa_d, @@ -437,19 +451,21 @@ def save(cls, entity: 'Proposition') -> None: and attrs.asdict(entity.checklist_actuelle, value_serializer=cls._serialize) or {}, }, - 'financability_computed_rule': entity.financabilite_regle_calcule.name - if entity.financabilite_regle_calcule - else '', - 'financability_computed_rule_situation': entity.financabilite_regle_calcule_situation.name - if entity.financabilite_regle_calcule_situation - else '', + 'financability_computed_rule': ( + entity.financabilite_regle_calcule.name if entity.financabilite_regle_calcule else '' + ), + 'financability_computed_rule_situation': ( + entity.financabilite_regle_calcule_situation.name + if entity.financabilite_regle_calcule_situation + else '' + ), 'financability_computed_rule_on': entity.financabilite_regle_calcule_le, 'financability_rule': entity.financabilite_regle.name if entity.financabilite_regle else '', 'financability_established_by': financabilite_etabli_par_person, 'financability_established_on': entity.financabilite_etabli_le, - 'financability_dispensation_status': entity.financabilite_derogation_statut.name - if entity.financabilite_derogation_statut - else '', + 'financability_dispensation_status': ( + entity.financabilite_derogation_statut.name if entity.financabilite_derogation_statut else '' + ), 'financability_dispensation_first_notification_on': ( entity.financabilite_derogation_premiere_notification_le ), @@ -474,29 +490,29 @@ def save(cls, entity: 'Proposition') -> None: 'join_program_fac_comment': entity.commentaire_programme_conjoint, 'admission_requirement': entity.condition_acces.name if entity.condition_acces else '', 'admission_requirement_year': academic_years.get(entity.millesime_condition_acces), - 'foreign_access_title_equivalency_type': entity.type_equivalence_titre_acces.name - if entity.type_equivalence_titre_acces - else '', + 'foreign_access_title_equivalency_type': ( + entity.type_equivalence_titre_acces.name if entity.type_equivalence_titre_acces else '' + ), 'foreign_access_title_equivalency_restriction_about': entity.information_a_propos_de_la_restriction, - 'foreign_access_title_equivalency_status': entity.statut_equivalence_titre_acces.name - if entity.statut_equivalence_titre_acces - else '', - 'foreign_access_title_equivalency_state': entity.etat_equivalence_titre_acces.name - if entity.etat_equivalence_titre_acces - else '', + 'foreign_access_title_equivalency_status': ( + entity.statut_equivalence_titre_acces.name if entity.statut_equivalence_titre_acces else '' + ), + 'foreign_access_title_equivalency_state': ( + entity.etat_equivalence_titre_acces.name if entity.etat_equivalence_titre_acces else '' + ), 'foreign_access_title_equivalency_effective_date': entity.date_prise_effet_equivalence_titre_acces, 'dispensation_needed': entity.besoin_de_derogation.name if entity.besoin_de_derogation else '', - 'tuition_fees_amount': entity.droits_inscription_montant.name - if entity.droits_inscription_montant - else '', + 'tuition_fees_amount': ( + entity.droits_inscription_montant.name if entity.droits_inscription_montant else '' + ), 'tuition_fees_amount_other': entity.droits_inscription_montant_autre, - 'tuition_fees_dispensation': entity.dispense_ou_droits_majores.name - if entity.dispense_ou_droits_majores - else '', + 'tuition_fees_dispensation': ( + entity.dispense_ou_droits_majores.name if entity.dispense_ou_droits_majores else '' + ), 'is_mobility': entity.est_mobilite, - 'mobility_months_amount': entity.nombre_de_mois_de_mobilite.name - if entity.nombre_de_mois_de_mobilite - else '', + 'mobility_months_amount': ( + entity.nombre_de_mois_de_mobilite.name if entity.nombre_de_mois_de_mobilite else '' + ), 'must_report_to_sic': entity.doit_se_presenter_en_sic, 'communication_to_the_candidate': entity.communication_au_candidat, 'must_provide_student_visa_d': entity.doit_fournir_visa_etudes, @@ -529,19 +545,25 @@ def _sauvegarder_comptabilite(cls, admission: DoctorateAdmission, entity: Propos admission=admission, defaults={ 'institute_absence_debts_certificate': entity.comptabilite.attestation_absence_dette_etablissement, - 'assimilation_situation': entity.comptabilite.type_situation_assimilation.name - if entity.comptabilite.type_situation_assimilation - else '', - 'assimilation_1_situation_type': entity.comptabilite.sous_type_situation_assimilation_1.name - if entity.comptabilite.sous_type_situation_assimilation_1 - else '', + 'assimilation_situation': ( + entity.comptabilite.type_situation_assimilation.name + if entity.comptabilite.type_situation_assimilation + else '' + ), + 'assimilation_1_situation_type': ( + entity.comptabilite.sous_type_situation_assimilation_1.name + if entity.comptabilite.sous_type_situation_assimilation_1 + else '' + ), 'long_term_resident_card': entity.comptabilite.carte_resident_longue_duree, 'cire_unlimited_stay_foreigner_card': entity.comptabilite.carte_cire_sejour_illimite_etranger, 'ue_family_member_residence_card': entity.comptabilite.carte_sejour_membre_ue, 'ue_family_member_permanent_residence_card': entity.comptabilite.carte_sejour_permanent_membre_ue, - 'assimilation_2_situation_type': entity.comptabilite.sous_type_situation_assimilation_2.name - if entity.comptabilite.sous_type_situation_assimilation_2 - else '', + 'assimilation_2_situation_type': ( + entity.comptabilite.sous_type_situation_assimilation_2.name + if entity.comptabilite.sous_type_situation_assimilation_2 + else '' + ), 'refugee_a_b_card': entity.comptabilite.carte_a_b_refugie, 'refugees_stateless_annex_25_26': entity.comptabilite.annexe_25_26_refugies_apatrides, 'registration_certificate': entity.comptabilite.attestation_immatriculation, @@ -550,20 +572,24 @@ def _sauvegarder_comptabilite(cls, admission: DoctorateAdmission, entity: Propos 'subsidiary_protection_decision': entity.comptabilite.decision_protection_subsidiaire, 'temporary_protection_decision': entity.comptabilite.decision_protection_temporaire, 'a_card': entity.comptabilite.carte_a, - 'assimilation_3_situation_type': entity.comptabilite.sous_type_situation_assimilation_3.name - if entity.comptabilite.sous_type_situation_assimilation_3 - else '', + 'assimilation_3_situation_type': ( + entity.comptabilite.sous_type_situation_assimilation_3.name + if entity.comptabilite.sous_type_situation_assimilation_3 + else '' + ), 'professional_3_month_residence_permit': entity.comptabilite.titre_sejour_3_mois_professionel, 'salary_slips': entity.comptabilite.fiches_remuneration, 'replacement_3_month_residence_permit': entity.comptabilite.titre_sejour_3_mois_remplacement, 'unemployment_benefit_pension_compensation_proof': unemployment_benefit_pension_proof, 'cpas_certificate': entity.comptabilite.attestation_cpas, - 'relationship': entity.comptabilite.relation_parente.name - if entity.comptabilite.relation_parente - else '', - 'assimilation_5_situation_type': entity.comptabilite.sous_type_situation_assimilation_5.name - if entity.comptabilite.sous_type_situation_assimilation_5 - else '', + 'relationship': ( + entity.comptabilite.relation_parente.name if entity.comptabilite.relation_parente else '' + ), + 'assimilation_5_situation_type': ( + entity.comptabilite.sous_type_situation_assimilation_5.name + if entity.comptabilite.sous_type_situation_assimilation_5 + else '' + ), 'household_composition_or_birth_certificate': entity.comptabilite.composition_menage_acte_naissance, 'tutorship_act': entity.comptabilite.acte_tutelle, 'household_composition_or_marriage_certificate': entity.comptabilite.composition_menage_acte_mariage, @@ -574,17 +600,19 @@ def _sauvegarder_comptabilite(cls, admission: DoctorateAdmission, entity: Propos 'parent_3_month_residence_permit': entity.comptabilite.titre_sejour_3_mois_parent, 'parent_salary_slips': entity.comptabilite.fiches_remuneration_parent, 'parent_cpas_certificate': entity.comptabilite.attestation_cpas_parent, - 'assimilation_6_situation_type': entity.comptabilite.sous_type_situation_assimilation_6.name - if entity.comptabilite.sous_type_situation_assimilation_6 - else '', + 'assimilation_6_situation_type': ( + entity.comptabilite.sous_type_situation_assimilation_6.name + if entity.comptabilite.sous_type_situation_assimilation_6 + else '' + ), 'cfwb_scholarship_decision': entity.comptabilite.decision_bourse_cfwb, 'scholarship_certificate': entity.comptabilite.attestation_boursier, 'ue_long_term_stay_identity_document': entity.comptabilite.titre_identite_sejour_longue_duree_ue, 'belgium_residence_permit': entity.comptabilite.titre_sejour_belgique, 'solidarity_student': entity.comptabilite.etudiant_solidaire, - 'account_number_type': entity.comptabilite.type_numero_compte.name - if entity.comptabilite.type_numero_compte - else '', + 'account_number_type': ( + entity.comptabilite.type_numero_compte.name if entity.comptabilite.type_numero_compte else '' + ), 'iban_account_number': entity.comptabilite.numero_compte_iban, 'valid_iban': entity.comptabilite.iban_valide, 'other_format_account_number': entity.comptabilite.numero_compte_autre_format, @@ -612,6 +640,7 @@ def search_dto( matricule_promoteur: Optional[str] = '', cotutelle: Optional[bool] = None, entity_ids: Optional[List['PropositionIdentity']] = None, + est_pre_admission_d_une_admission_en_cours: Optional[bool] = None, ) -> List['PropositionDTO']: qs = PropositionProxy.objects.for_dto().all() if numero is not None: @@ -658,6 +687,15 @@ def search_dto( if entity_ids is not None: qs = qs.filter(uuid__in=[entity_id.uuid for entity_id in entity_ids]) + if est_pre_admission_d_une_admission_en_cours is not None: + qs = qs.alias( + already_associated_to_proposition_in_progress=Exists( + DoctorateAdmission.objects.filter(related_pre_admission_id=OuterRef('pk')).exclude( + status=ChoixStatutPropositionDoctorale.ANNULEE.name, + ), + ) + ).filter(already_associated_to_proposition_in_progress=est_pre_admission_d_une_admission_en_cours) + return [cls._load_dto(admission) for admission in qs] @classmethod @@ -669,6 +707,7 @@ def _load_dto(cls, admission: DoctorateAdmission) -> 'PropositionDTO': return PropositionDTO( uuid=admission.uuid, reference=admission.formatted_reference, # from annotation + pre_admission_associee=str(admission.related_pre_admission.uuid) if admission.related_pre_admission else '', type_admission=admission.type, doctorat=DoctoratFormationDTO( sigle=admission.doctorate.acronym, @@ -705,9 +744,11 @@ def _load_dto(cls, admission: DoctorateAdmission) -> 'PropositionDTO': type_financement=admission.financing_type, type_contrat_travail=admission.financing_work_contract, eft=admission.financing_eft, - bourse_recherche=BourseTranslator.build_dto(admission.international_scholarship) - if admission.international_scholarship - else None, + bourse_recherche=( + BourseTranslator.build_dto(admission.international_scholarship) + if admission.international_scholarship + else None + ), autre_bourse_recherche=admission.other_international_scholarship, bourse_date_debut=admission.scholarship_start_date, bourse_date_fin=admission.scholarship_end_date, @@ -756,9 +797,9 @@ def _load_dto(cls, admission: DoctorateAdmission) -> 'PropositionDTO': financabilite_regle_calcule_situation=admission.financability_computed_rule_situation, financabilite_regle_calcule_le=admission.financability_computed_rule_on, financabilite_regle=admission.financability_rule, - financabilite_etabli_par=admission.financability_established_by.global_id - if admission.financability_established_by - else '', + financabilite_etabli_par=( + admission.financability_established_by.global_id if admission.financability_established_by else '' + ), financabilite_etabli_le=admission.financability_established_on, financabilite_derogation_statut=admission.financability_dispensation_status, financabilite_derogation_premiere_notification_le=( @@ -818,29 +859,34 @@ def _load_dto_for_gestionnaire( noma_candidat=admission.student_registration_id or '', # from annotation photo_identite_candidat=admission.candidate.id_photo, adresse_email_candidat=admission.candidate.private_email, - cotutelle=CotutelleDTO( - cotutelle=admission.cotutelle, - motivation=admission.cotutelle_motivation, - institution_fwb=admission.cotutelle_institution_fwb, - institution=admission.cotutelle_institution, - demande_ouverture=admission.cotutelle_opening_request, - convention=admission.cotutelle_convention, - autres_documents=admission.cotutelle_other_documents, - autre_institution=bool( - admission.cotutelle_other_institution_name or admission.cotutelle_other_institution_address - ), - autre_institution_nom=admission.cotutelle_other_institution_name, - autre_institution_adresse=admission.cotutelle_other_institution_address, - ) - if admission.cotutelle - else None, - profil_soumis_candidat=ProfilCandidatDTO.from_dict( - dict_profile=admission.submitted_profile, - nom_pays_nationalite=admission.submitted_profile_country_of_citizenship_name or '', # from annotation - nom_pays_adresse=admission.submitted_profile_country_name or '', # from annotation - ) - if admission.submitted_profile - else None, + cotutelle=( + CotutelleDTO( + cotutelle=admission.cotutelle, + motivation=admission.cotutelle_motivation, + institution_fwb=admission.cotutelle_institution_fwb, + institution=admission.cotutelle_institution, + demande_ouverture=admission.cotutelle_opening_request, + convention=admission.cotutelle_convention, + autres_documents=admission.cotutelle_other_documents, + autre_institution=bool( + admission.cotutelle_other_institution_name or admission.cotutelle_other_institution_address + ), + autre_institution_nom=admission.cotutelle_other_institution_name, + autre_institution_adresse=admission.cotutelle_other_institution_address, + ) + if admission.cotutelle + else None + ), + profil_soumis_candidat=( + ProfilCandidatDTO.from_dict( + dict_profile=admission.submitted_profile, + nom_pays_nationalite=admission.submitted_profile_country_of_citizenship_name + or '', # from annotation + nom_pays_adresse=admission.submitted_profile_country_name or '', # from annotation + ) + if admission.submitted_profile + else None + ), motifs_refus=[ MotifRefusDTO(motif=mark_safe(reason.name), categorie=reason.category.name) for reason in admission.refusal_reasons.all() @@ -857,9 +903,9 @@ def _load_dto_for_gestionnaire( email_personne_contact_programme_annuel_annuel=admission.annual_program_contact_person_email, commentaire_programme_conjoint=admission.join_program_fac_comment, condition_acces=admission.admission_requirement, - millesime_condition_acces=admission.admission_requirement_year.year - if admission.admission_requirement_year - else None, + millesime_condition_acces=( + admission.admission_requirement_year.year if admission.admission_requirement_year else None + ), type_equivalence_titre_acces=admission.foreign_access_title_equivalency_type, information_a_propos_de_la_restriction=admission.foreign_access_title_equivalency_restriction_about, statut_equivalence_titre_acces=admission.foreign_access_title_equivalency_status, diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index cd82b5dc8..c9dd2238c 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -1215,9 +1215,6 @@ msgid "" "number" msgstr "" -msgid "Canvas" -msgstr "" - msgid "Carbon-copy the CA members" msgstr "" @@ -1494,9 +1491,6 @@ msgstr "" msgid "Confirmation exam date" msgstr "" -msgid "Confirmation success attestation" -msgstr "" - msgid "Contact" msgstr "" @@ -5259,6 +5253,9 @@ msgstr "" msgid "Related experience" msgstr "" +msgid "Related pre-admission" +msgstr "" + msgid "Relationship" msgstr "" @@ -6021,6 +6018,9 @@ msgstr "" msgid "The University tuition fee is EUR 0.0." msgstr "" +msgid "The admission must not follow a pre-admission" +msgstr "" + #, python-format msgid "" "The applicant is registered in the year %(year)s. Click %(name)s" - -#~ msgid "Role: ADRE Secretaries" -#~ msgstr "Rôle: Secrétaires ADRE" - -#~ msgid "Role: ADRE secretary" -#~ msgstr "Rôle: Secrétaire ADRE" - -#~ msgid "Template title" -#~ msgstr "Intitulé du template" diff --git a/migrations/0237_doctorateadmission_related_pre_admission.py b/migrations/0237_doctorateadmission_related_pre_admission.py new file mode 100644 index 000000000..d34ce35b1 --- /dev/null +++ b/migrations/0237_doctorateadmission_related_pre_admission.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2024-12-03 12:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("admission", "0236_parcours_doctoral_refactoring"), + ] + + operations = [ + migrations.AddField( + model_name="doctorateadmission", + name="related_pre_admission", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="admission.doctorateadmission", + verbose_name="Related pre-admission", + ), + ), + ] diff --git a/models/doctorate.py b/models/doctorate.py index deda69f39..984b50233 100644 --- a/models/doctorate.py +++ b/models/doctorate.py @@ -89,6 +89,14 @@ class DoctorateAdmission(BaseAdmission): db_index=True, default=ChoixTypeAdmission.ADMISSION.name, ) + related_pre_admission = models.ForeignKey( + 'self', + verbose_name=_('Related pre-admission'), + on_delete=models.PROTECT, + blank=True, + null=True, + related_name='+', + ) # TODO: remove this field in the future valuated_experiences = models.ManyToManyField( 'osis_profile.Experience', @@ -803,6 +811,7 @@ def for_dto(self): self.get_queryset() .select_related( 'training__enrollment_campus__country', + 'related_pre_admission', ) .annotate_campus_info() .annotate_training_management_entity() diff --git a/schema.yml b/schema.yml index 2369836f7..867142f9f 100644 --- a/schema.yml +++ b/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: Admission API - version: 1.0.110 + version: 1.0.111 description: This API delivers data for the Admission project. contact: name: UCLouvain - OSIS @@ -605,6 +605,33 @@ paths: $ref: '#/components/responses/NotFound' tags: - person + /propositions/doctorate/pre-admission-list: + get: + operationId: list_doctorate_pre_admissions + description: '' + parameters: + - $ref: '#/components/parameters/Accept-Language' + - $ref: '#/components/parameters/X-User-FirstName' + - $ref: '#/components/parameters/X-User-LastName' + - $ref: '#/components/parameters/X-User-Email' + - $ref: '#/components/parameters/X-User-GlobalID' + responses: + 200: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DoctoratePreAdmissionSearchDTO' + description: '' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + tags: + - propositions /propositions/doctorate/{uuid}: get: operationId: retrieve_doctorate_proposition @@ -5275,6 +5302,8 @@ components: properties: uuid: type: string + pre_admission_associee: + type: string reference: type: string type_admission: @@ -5787,6 +5816,8 @@ components: properties: uuid: type: string + pre_admission_associee: + type: string reference: type: string type_admission: @@ -6584,11 +6615,61 @@ components: - educational_experiences - incomplete_periods - incomplete_experiences + DoctoratePreAdmissionSearchDTO: + type: object + properties: + uuid: + type: string + reference: + type: string + doctorat: + type: object + properties: + sigle: + type: string + code: + type: string + annee: + type: integer + intitule: + type: string + sigle_entite_gestion: + type: string + campus: + type: object + properties: + uuid: + type: string + format: uuid + nom: + type: string + required: + - uuid + - nom + required: + - sigle + - code + - annee + - intitule + - sigle_entite_gestion + - campus + code_secteur_formation: + type: string + intitule_secteur_formation: + type: string + required: + - uuid + - reference + - doctorat + - code_secteur_formation + - intitule_secteur_formation DoctoratePropositionDTO: type: object properties: uuid: type: string + pre_admission_associee: + type: string type_admission: type: string reference: @@ -8739,11 +8820,14 @@ components: properties: type_admission: $ref: '#/components/schemas/ChoixTypeAdmission' + matricule_candidat: + type: string sigle_formation: type: string annee_formation: type: integer - matricule_candidat: + nullable: true + pre_admission_associee: type: string justification: type: string @@ -8769,8 +8853,6 @@ components: type: string required: - type_admission - - sigle_formation - - annee_formation - matricule_candidat - commission_proximite ChoixTypeAdmission: diff --git a/tests/api/views/test_project.py b/tests/api/views/test_project.py index b727e1553..3ec06b414 100644 --- a/tests/api/views/test_project.py +++ b/tests/api/views/test_project.py @@ -1069,3 +1069,197 @@ def test_submit_invalid_proposition_using_api_specific_questions(self, mock_is_e response = self.client.post(url, self.submitted_data) self.assertNotInErrors(response, QuestionsSpecifiquesCurriculumNonCompleteesException) + + +@override_settings(WAFFLE_CREATE_MISSING_SWITCHES=False) +class DoctoratePreAdmissionListTestCase(QueriesAssertionsMixin, CheckActionLinksMixin, APITestCase): + @classmethod + @freezegun.freeze_time('2023-01-01') + def setUpTestData(cls): + # Create supervision group members + cls.promoter = PromoterFactory() + cls.committee_member = CaMemberFactory(process=cls.promoter.process) + + # Create doctorate management entity + root = EntityVersionFactory(parent=None).entity + cls.sector = EntityVersionFactory( + parent=root, + entity_type=EntityType.SECTOR.name, + acronym='SST', + ) + cls.commission = EntityVersionFactory( + parent=cls.sector.entity, + entity_type=EntityType.DOCTORAL_COMMISSION.name, + acronym='CDA', + ) + + # Users + cls.candidate = CandidateFactory().person + cls.no_role_user = PersonFactory().user + cls.promoter_user = cls.promoter.person.user + cls.committee_member_user = cls.committee_member.person.user + + cls.url = resolve_url("admission_api_v1:doctorate_pre_admission_list") + + def test_list_with_no_pre_admission(self): + self.client.force_authenticate(user=self.candidate.user) + + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 0) + + def test_list_with_pre_admissions_to_retrieve(self): + self.client.force_authenticate(user=self.candidate.user) + + admission: DoctorateAdmission = DoctorateAdmissionFactory( + candidate=self.candidate, + training__management_entity=self.commission.entity, + determined_academic_year__year=2022, + status=ChoixStatutPropositionDoctorale.INSCRIPTION_AUTORISEE.name, + type=ChoixTypeAdmission.PRE_ADMISSION.name, + ) + + with self.assertNumQueriesLessThan(4): + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 1) + + self.assertEqual(results[0]['uuid'], str(admission.uuid)) + self.assertEqual(results[0]['reference'], f'M-CDA22-{str(admission)}') + self.assertEqual(results[0]['doctorat']['sigle'], admission.training.acronym) + self.assertEqual(results[0]['doctorat']['code'], admission.training.partial_acronym) + self.assertEqual(results[0]['doctorat']['annee'], admission.training.academic_year.year) + self.assertEqual(results[0]['doctorat']['intitule'], admission.training.title) + self.assertEqual(results[0]['doctorat']['sigle_entite_gestion'], self.commission.acronym) + + training_campus = admission.training.educationgroupversion_set.first().root_group.main_teaching_campus + self.assertEqual(results[0]['doctorat']['campus']['uuid'], str(training_campus.uuid)) + self.assertEqual(results[0]['doctorat']['campus']['nom'], training_campus.name) + + self.assertEqual(results[0]['code_secteur_formation'], self.sector.acronym) + self.assertEqual(results[0]['intitule_secteur_formation'], self.sector.title) + + other_admission = DoctorateAdmissionFactory( + candidate=self.candidate, + training=admission.training, + status=ChoixStatutPropositionDoctorale.INSCRIPTION_AUTORISEE.name, + type=ChoixTypeAdmission.PRE_ADMISSION.name, + ) + + with self.assertNumQueriesLessThan(4, verbose=True): + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 2) + + def test_list_depending_on_admission_type(self): + self.client.force_authenticate(user=self.candidate.user) + + admission: DoctorateAdmission = DoctorateAdmissionFactory( + candidate=self.candidate, + training__management_entity=self.commission.entity, + status=ChoixStatutPropositionDoctorale.INSCRIPTION_AUTORISEE.name, + type=ChoixTypeAdmission.ADMISSION.name, + ) + + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 0) + + admission.type = ChoixTypeAdmission.PRE_ADMISSION.name + admission.save() + + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 1) + + def test_list_depending_on_admission_status(self): + self.client.force_authenticate(user=self.candidate.user) + + admission: DoctorateAdmission = DoctorateAdmissionFactory( + candidate=self.candidate, + training__management_entity=self.commission.entity, + status=ChoixStatutPropositionDoctorale.RETOUR_DE_FAC.name, + type=ChoixTypeAdmission.PRE_ADMISSION.name, + ) + + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 0) + + admission.status = ChoixStatutPropositionDoctorale.INSCRIPTION_AUTORISEE.name + admission.save() + + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 1) + + def test_list_depending_on_other_pre_admissions_associations(self): + self.client.force_authenticate(user=self.candidate.user) + + admission: DoctorateAdmission = DoctorateAdmissionFactory( + candidate=self.candidate, + training__management_entity=self.commission.entity, + status=ChoixStatutPropositionDoctorale.INSCRIPTION_AUTORISEE.name, + type=ChoixTypeAdmission.PRE_ADMISSION.name, + ) + + other_admission = DoctorateAdmissionFactory( + candidate=self.candidate, + related_pre_admission=admission, + status=ChoixStatutPropositionDoctorale.EN_BROUILLON.name, + ) + + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 0) + + other_admission.status = ChoixStatutPropositionDoctorale.ANNULEE.name + other_admission.save() + + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.json() + + self.assertEqual(len(results), 1) + + def test_assert_methods_not_allowed(self): + self.client.force_authenticate(user=self.candidate.user) + methods_not_allowed = ['delete', 'put', 'patch', 'post'] + + for method in methods_not_allowed: + response = getattr(self.client, method)(self.url) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/tests/api/views/test_signatures.py b/tests/api/views/test_signatures.py index 34ce624e0..487203beb 100644 --- a/tests/api/views/test_signatures.py +++ b/tests/api/views/test_signatures.py @@ -157,9 +157,10 @@ def test_request_signatures_using_api_cotutelle_without_external_promoter_must_f response = self.client.post(url) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()['non_field_errors'][0]['status_code'], + status_codes = [e['status_code'] for e in response.json()['non_field_errors']] + self.assertIn( CotutelleDoitAvoirAuMoinsUnPromoteurExterneException.status_code, + status_codes, ) def test_request_signatures_using_api_cotutelle_with_external_promoter(self): diff --git a/tests/api/views/test_training_choice.py b/tests/api/views/test_training_choice.py index 3e900acd6..e71cf775a 100644 --- a/tests/api/views/test_training_choice.py +++ b/tests/api/views/test_training_choice.py @@ -26,6 +26,7 @@ import datetime import uuid +from typing import Dict, List from unittest.mock import patch import freezegun @@ -33,15 +34,16 @@ from django.db.models import QuerySet from django.shortcuts import resolve_url from django.test import override_settings +from django.utils.translation import gettext from osis_history.models import HistoryEntry from rest_framework import status from rest_framework.test import APITestCase -from admission.models import ContinuingEducationAdmission, DoctorateAdmission, GeneralEducationAdmission -from admission.models.base import REFERENCE_SEQ_NAME from admission.ddd.admission.doctorat.preparation.domain.model.enums import ( ChoixStatutPropositionDoctorale, ChoixTypeAdmission, + ChoixTypeContratTravail, + ChoixDoctoratDejaRealise, ) from admission.ddd.admission.doctorat.preparation.domain.validator import exceptions as doctorate_education_exceptions from admission.ddd.admission.doctorat.preparation.domain.validator.exceptions import ( @@ -57,6 +59,14 @@ ChoixStatutPropositionGenerale, ) from admission.ddd.admission.formation_generale.domain.validator import exceptions as general_education_exceptions +from admission.models import ( + ContinuingEducationAdmission, + DoctorateAdmission, + GeneralEducationAdmission, + SupervisionActor, +) +from admission.models.base import REFERENCE_SEQ_NAME +from admission.models.enums.actor_type import ActorType from admission.tests.factories import DoctorateAdmissionFactory from admission.tests.factories.calendar import AdmissionAcademicCalendarFactory from admission.tests.factories.continuing_education import ( @@ -76,12 +86,15 @@ ErasmusMundusScholarshipFactory, InternationalScholarshipFactory, ) -from admission.tests.factories.supervision import CaMemberFactory, PromoterFactory +from admission.tests.factories.supervision import CaMemberFactory, PromoterFactory, ExternalPromoterFactory +from base.forms.utils.file_field import PDF_MIME_TYPE from base.models.enums.entity_type import EntityType from base.tests import QueriesAssertionsMixin from base.tests.factories.education_group_year import Master120TrainingFactory from base.tests.factories.entity_version import EntityVersionFactory from base.tests.factories.person import PersonFactory +from parcours_doctoral.ddd.domain.model.enums import ChoixCommissionProximiteCDSS, ChoixTypeFinancement +from reference.tests.factories.language import LanguageFactory def create_default_propositions_in_progress(candidate): @@ -113,6 +126,7 @@ def setUpTestData(cls): enrollment_campus__name='Mons', ) cls.scholarship = ErasmusMundusScholarshipFactory() + cls.language = LanguageFactory(code='EN') AdmissionAcademicCalendarFactory.produce_all_required() cls.create_data = { @@ -126,6 +140,73 @@ def setUpTestData(cls): cls.url = resolve_url("admission_api_v1:doctorate_training_choice") cls.list_url = resolve_url("admission_api_v1:propositions") + cls.documents_names = [ + 'scholarship_proof', + 'project_document', + 'gantt_graph', + 'program_proposition', + 'additional_training_project', + 'recommendation_letters', + 'curriculum', + ] + + cls.documents_tokens: Dict[str, List[uuid.UUID]] = {} + cls.duplicated_documents_tokens: Dict[str, List[uuid.UUID]] = {} + cls.duplicated_documents_tokens_by_uuid: Dict[str, str] = {} + + for document_name in cls.documents_names: + cls.documents_tokens[document_name] = [uuid.uuid4()] + cls.duplicated_documents_tokens[document_name] = [uuid.uuid4()] + cls.duplicated_documents_tokens_by_uuid[str(cls.documents_tokens[document_name][0])] = str( + cls.duplicated_documents_tokens[document_name][0], + ) + + def setUp(self): + # Mock documents + patcher = patch('osis_document.api.utils.get_remote_tokens') + patched = patcher.start() + patched.side_effect = lambda uuids, **kwargs: {uuid: f'token-{index}' for index, uuid in enumerate(uuids)} + self.addCleanup(patcher.stop) + + patcher = patch('osis_document.api.utils.get_several_remote_metadata') + patched = patcher.start() + patched.side_effect = lambda tokens: { + token: { + 'name': 'myfile', + 'mimetype': PDF_MIME_TYPE, + 'size': 1, + } + for token in tokens + } + self.addCleanup(patcher.stop) + + patcher = patch("osis_document.api.utils.get_remote_token", return_value="foobar") + patcher.start() + self.addCleanup(patcher.stop) + + patcher = patch("osis_document.api.utils.get_remote_metadata", return_value={"name": "myfile", "size": 1}) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = patch( + "osis_document.api.utils.confirm_remote_upload", + side_effect=lambda token, *args, **kwargs: token, + ) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = patch( + "osis_document.contrib.fields.FileField._confirm_multiple_upload", + side_effect=lambda _, value, __: value, + ) + patcher.start() + self.addCleanup(patcher.stop) + + self.documents_remote_duplicate_patcher = patch('osis_document.api.utils.documents_remote_duplicate') + self.documents_remote_duplicate_patched = self.documents_remote_duplicate_patcher.start() + self.documents_remote_duplicate_patched.return_value = self.duplicated_documents_tokens_by_uuid + self.addCleanup(self.documents_remote_duplicate_patcher.stop) + @freezegun.freeze_time('2023-01-01') def test_admission_doctorate_creation_using_api_candidate(self): self.client.force_authenticate(user=self.candidate.user) @@ -152,6 +233,203 @@ def test_admission_doctorate_creation_using_api_candidate(self): ) self.assertEqual(response.json()['doctorate_propositions'][0]["reference"], f'M-CDA22-{str(admission)}') + @freezegun.freeze_time('2023-01-01') + def test_admission_doctorate_creation_based_on_pre_admission(self): + self.client.force_authenticate(user=self.candidate.user) + + existing_promoter = PromoterFactory( + is_reference_promoter=True, + internal_comment='Internal comment 1', + rejection_reason='Rejection reason 1', + comment='Comment 1', + pdf_from_candidate=[uuid.uuid4()], + ) + external_promoter = ExternalPromoterFactory( + process=existing_promoter.process, + internal_comment='Internal comment 2', + rejection_reason='Rejection reason 2', + comment='Comment 2', + pdf_from_candidate=[uuid.uuid4()], + ) + existing_ca_member = CaMemberFactory( + process=existing_promoter.process, + internal_comment='Internal comment 3', + rejection_reason='Rejection reason 3', + comment='Comment 3', + pdf_from_candidate=[uuid.uuid4()], + ) + + pre_admission = DoctorateAdmissionFactory( + supervision_group=existing_promoter.process, + candidate=self.candidate, + training=self.doctorate, + type=ChoixTypeAdmission.PRE_ADMISSION.name, + status=ChoixStatutPropositionDoctorale.INSCRIPTION_AUTORISEE.name, + comment='Comment', + proximity_commission=ChoixCommissionProximiteCDSS.ECLI.name, + financing_type=ChoixTypeFinancement.SELF_FUNDING.name, + financing_work_contract=ChoixTypeContratTravail.UCLOUVAIN_SCIENTIFIC_STAFF.name, + financing_eft=10, + international_scholarship_id=self.scholarship.pk, + other_international_scholarship='Other scholarship', + scholarship_start_date=datetime.date(2020, 1, 1), + scholarship_end_date=datetime.date(2021, 1, 1), + scholarship_proof=self.documents_tokens['scholarship_proof'], + planned_duration=10, + dedicated_time=12, + is_fnrs_fria_fresh_csc_linked=True, + financing_comment='Financing comment', + project_title='Project title', + project_abstract='Project abstract', + thesis_language=self.language, + thesis_institute=EntityVersionFactory(), + thesis_location='Thesis location', + phd_alread_started=True, + phd_alread_started_institute='PHD already started institute', + work_start_date=datetime.date(2022, 1, 1), + project_document=self.documents_tokens['project_document'], + gantt_graph=self.documents_tokens['gantt_graph'], + program_proposition=self.documents_tokens['program_proposition'], + additional_training_project=self.documents_tokens['additional_training_project'], + recommendation_letters=self.documents_tokens['recommendation_letters'], + phd_already_done=ChoixDoctoratDejaRealise.YES.name, + phd_already_done_institution='PhD already done institution', + phd_already_done_thesis_domain='PhD already done thesis domain', + phd_already_done_defense_date=datetime.date(2023, 1, 1), + phd_already_done_no_defense_reason='No defense reason', + curriculum=self.documents_tokens['curriculum'], + ) + + with connection.cursor() as cursor: + cursor.execute('SELECT last_value FROM %(sequence)s' % {'sequence': REFERENCE_SEQ_NAME}) + seq_value = cursor.fetchone()[0] + + response = self.client.post( + self.url, + data={ + 'type_admission': ChoixTypeAdmission.ADMISSION.name, + 'justification': 'Some new justification', + 'sigle_formation': '', + 'annee_formation': None, + 'matricule_candidat': self.candidate.global_id, + 'commission_proximite': '', + 'pre_admission_associee': pre_admission.uuid, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + admissions = DoctorateAdmission.objects.filter(type=ChoixTypeAdmission.ADMISSION.name) + + self.assertEqual(admissions.count(), 1) + + new_admission = admissions[0] + self.assertEqual(new_admission.candidate, new_admission.candidate) + self.assertEqual(new_admission.training, new_admission.training) + self.assertEqual(new_admission.type, ChoixTypeAdmission.ADMISSION.name) + self.assertEqual(new_admission.status, ChoixStatutPropositionDoctorale.EN_BROUILLON.name) + self.assertEqual(new_admission.comment, 'Some new justification') + self.assertEqual(new_admission.proximity_commission, new_admission.proximity_commission) + self.assertEqual(new_admission.financing_type, new_admission.financing_type) + self.assertEqual(new_admission.financing_work_contract, new_admission.financing_work_contract) + self.assertEqual(new_admission.financing_eft, new_admission.financing_eft) + self.assertEqual(new_admission.international_scholarship_id, new_admission.international_scholarship_id) + self.assertEqual(new_admission.other_international_scholarship, new_admission.other_international_scholarship) + self.assertEqual(new_admission.scholarship_start_date, new_admission.scholarship_start_date) + self.assertEqual(new_admission.scholarship_end_date, new_admission.scholarship_end_date) + self.assertEqual(new_admission.scholarship_proof, self.duplicated_documents_tokens['scholarship_proof']) + self.assertEqual(new_admission.planned_duration, new_admission.planned_duration) + self.assertEqual(new_admission.dedicated_time, new_admission.dedicated_time) + self.assertEqual(new_admission.is_fnrs_fria_fresh_csc_linked, new_admission.is_fnrs_fria_fresh_csc_linked) + self.assertEqual(new_admission.financing_comment, new_admission.financing_comment) + self.assertEqual(new_admission.project_title, new_admission.project_title) + self.assertEqual(new_admission.project_abstract, new_admission.project_abstract) + self.assertEqual(new_admission.thesis_language, new_admission.thesis_language) + self.assertEqual(new_admission.thesis_institute, new_admission.thesis_institute) + self.assertEqual(new_admission.thesis_location, new_admission.thesis_location) + self.assertEqual(new_admission.phd_alread_started, new_admission.phd_alread_started) + self.assertEqual(new_admission.phd_alread_started_institute, new_admission.phd_alread_started_institute) + self.assertEqual(new_admission.work_start_date, new_admission.work_start_date) + self.assertEqual(new_admission.project_document, self.duplicated_documents_tokens['project_document']) + self.assertEqual(new_admission.gantt_graph, self.duplicated_documents_tokens['gantt_graph']) + self.assertEqual(new_admission.program_proposition, self.duplicated_documents_tokens['program_proposition']) + self.assertEqual( + new_admission.additional_training_project, + self.duplicated_documents_tokens['additional_training_project'], + ) + self.assertEqual( + new_admission.recommendation_letters, + self.duplicated_documents_tokens['recommendation_letters'], + ) + self.assertEqual(new_admission.phd_already_done, new_admission.phd_already_done) + self.assertEqual(new_admission.phd_already_done_institution, new_admission.phd_already_done_institution) + self.assertEqual(new_admission.phd_already_done_thesis_domain, new_admission.phd_already_done_thesis_domain) + self.assertEqual(new_admission.phd_already_done_defense_date, new_admission.phd_already_done_defense_date) + self.assertEqual( + new_admission.phd_already_done_no_defense_reason, + new_admission.phd_already_done_no_defense_reason, + ) + self.assertEqual(new_admission.curriculum, self.duplicated_documents_tokens['curriculum']) + + # Check the duplication of the supervision group + self.assertIsNotNone(new_admission.supervision_group) + + actors = SupervisionActor.objects.filter(process=new_admission.supervision_group) + + self.assertEqual(len(actors), 3) + + duplicated_external_promoter = actors.filter(type=ActorType.PROMOTER.name, person__isnull=True).first() + duplicated_existing_promoter = actors.filter(type=ActorType.PROMOTER.name, person__isnull=False).first() + duplicated_existing_ca_member = actors.filter(type=ActorType.CA_MEMBER.name).first() + + self.assertEqual(duplicated_external_promoter.type, external_promoter.type) + self.assertEqual(duplicated_external_promoter.is_doctor, external_promoter.is_doctor) + self.assertEqual(duplicated_external_promoter.internal_comment, '') + self.assertEqual(duplicated_external_promoter.rejection_reason, '') + self.assertEqual(duplicated_external_promoter.pdf_from_candidate, []) + self.assertEqual(duplicated_external_promoter.is_reference_promoter, False) + self.assertEqual(duplicated_external_promoter.person, external_promoter.person) + self.assertEqual(duplicated_external_promoter.first_name, external_promoter.first_name) + self.assertEqual(duplicated_external_promoter.last_name, external_promoter.last_name) + self.assertEqual(duplicated_external_promoter.email, external_promoter.email) + self.assertEqual(duplicated_external_promoter.institute, external_promoter.institute) + self.assertEqual(duplicated_external_promoter.city, external_promoter.city) + self.assertEqual(duplicated_external_promoter.country, external_promoter.country) + self.assertEqual(duplicated_external_promoter.language, external_promoter.language) + self.assertEqual(duplicated_external_promoter.comment, '') + + self.assertEqual(duplicated_existing_promoter.type, existing_promoter.type) + self.assertEqual(duplicated_existing_promoter.is_doctor, existing_promoter.is_doctor) + self.assertEqual(duplicated_existing_promoter.internal_comment, '') + self.assertEqual(duplicated_existing_promoter.rejection_reason, '') + self.assertEqual(duplicated_existing_promoter.pdf_from_candidate, []) + self.assertEqual(duplicated_existing_promoter.is_reference_promoter, True) + self.assertEqual(duplicated_existing_promoter.person, existing_promoter.person) + self.assertEqual(duplicated_existing_promoter.first_name, existing_promoter.first_name) + self.assertEqual(duplicated_existing_promoter.last_name, existing_promoter.last_name) + self.assertEqual(duplicated_existing_promoter.email, existing_promoter.email) + self.assertEqual(duplicated_existing_promoter.institute, existing_promoter.institute) + self.assertEqual(duplicated_existing_promoter.city, existing_promoter.city) + self.assertEqual(duplicated_existing_promoter.country, existing_promoter.country) + self.assertEqual(duplicated_existing_promoter.language, existing_promoter.language) + self.assertEqual(duplicated_existing_promoter.comment, '') + + self.assertEqual(duplicated_existing_ca_member.type, existing_ca_member.type) + self.assertEqual(duplicated_existing_ca_member.is_doctor, existing_ca_member.is_doctor) + self.assertEqual(duplicated_existing_ca_member.internal_comment, '') + self.assertEqual(duplicated_existing_ca_member.rejection_reason, '') + self.assertEqual(duplicated_existing_ca_member.pdf_from_candidate, []) + self.assertEqual(duplicated_existing_ca_member.is_reference_promoter, False) + self.assertEqual(duplicated_existing_ca_member.person, existing_ca_member.person) + self.assertEqual(duplicated_existing_ca_member.first_name, existing_ca_member.first_name) + self.assertEqual(duplicated_existing_ca_member.last_name, existing_ca_member.last_name) + self.assertEqual(duplicated_existing_ca_member.email, existing_ca_member.email) + self.assertEqual(duplicated_existing_ca_member.institute, existing_ca_member.institute) + self.assertEqual(duplicated_existing_ca_member.city, existing_ca_member.city) + self.assertEqual(duplicated_existing_ca_member.country, existing_ca_member.country) + self.assertEqual(duplicated_existing_ca_member.language, existing_ca_member.language) + self.assertEqual(duplicated_existing_ca_member.comment, '') + def test_admission_doctorate_creation_using_api_with_wrong_doctorate(self): self.client.force_authenticate(user=self.candidate.user) data = {**self.create_data, "sigle_formation": "UNKONWN"} @@ -695,6 +973,33 @@ def test_admission_type_update_using_api_candidate(self): } self.assertEqual(admission.specific_question_answers, expected) + def test_admission_type_update_with_admission_based_on_pre_admission(self): + self.client.force_authenticate(user=self.candidate.user) + + pre_admission = DoctorateAdmissionFactory( + candidate=self.candidate, + training=self.admission.training, + type=ChoixTypeAdmission.PRE_ADMISSION.name, + status=ChoixStatutPropositionDoctorale.INSCRIPTION_AUTORISEE.name, + ) + + admission = DoctorateAdmissionFactory( + candidate=self.candidate, + training=self.admission.training, + type=ChoixTypeAdmission.ADMISSION.name, + related_pre_admission=pre_admission, + ) + + url = resolve_url('admission_api_v1:doctorate_admission_type_update', uuid=str(admission.uuid)) + + response = self.client.put(url, data={}, format='json') + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.json()['detail'], + gettext('The admission must not follow a pre-admission'), + ) + def test_admission_type_update_using_api_candidate_with_wrong_proposition(self): self.client.force_authenticate(user=self.candidate.user) data = {**self.update_data, 'uuid_proposition': str(uuid.uuid4())} diff --git a/tests/exports/test_admission_recap.py b/tests/exports/test_admission_recap.py index e6c978e66..09274d167 100644 --- a/tests/exports/test_admission_recap.py +++ b/tests/exports/test_admission_recap.py @@ -1489,6 +1489,7 @@ def setUpTestData(cls): ) doctorate_proposition_dto = _PropositionFormationDoctoraleDTO( uuid='uuid-proposition', + pre_admission_associee='', doctorat=DoctoratFormationDTO( sigle='FD1', annee=2023,