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 7702a96e6..01a2af8ac 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( @@ -928,6 +926,7 @@ def has_add_permission(self, request) -> bool: def has_change_permission(self, request, obj=None) -> bool: return False + # ############################################################################## # Roles diff --git a/admission_utils/copy_documents.py b/admission_utils/copy_documents.py new file mode 100644 index 000000000..7bd130893 --- /dev/null +++ b/admission_utils/copy_documents.py @@ -0,0 +1,94 @@ +# ############################################################################## +# +# 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-2025 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/. +# +# ############################################################################## +import uuid + + +def copy_documents(objs): + """ + Create copies of the files of the specified objects and affect them to the specified objects. + :param objs: The list of objects. + """ + from osis_document.api.utils import get_several_remote_metadata, get_remote_tokens, documents_remote_duplicate + from osis_document.contrib import FileField + from osis_document.utils import generate_filename + + all_document_uuids = [] + all_document_upload_paths = {} + document_fields_by_obj_uuid = {} + + # Get all the document fields and the uuids of the documents to duplicate + for obj in objs: + document_fields_by_obj_uuid[obj.uuid] = {} + + for field in obj._meta.get_fields(): + if isinstance(field, FileField): + document_uuids = getattr(obj, field.name) + + if document_uuids: + document_fields_by_obj_uuid[obj.uuid][field.name] = field + all_document_uuids += [document_uuid for document_uuid in document_uuids if document_uuid] + + all_tokens = get_remote_tokens(all_document_uuids) + metadata_by_token = get_several_remote_metadata(tokens=list(all_tokens.values())) + + # Get the upload paths of the documents to duplicate + for obj in objs: + for field_name, field in document_fields_by_obj_uuid[obj.uuid].items(): + document_uuids = getattr(obj, field_name) + + for document_uuid in document_uuids: + if not document_uuid: + continue + + document_uuid_str = str(document_uuid) + file_name = 'file' + + if document_uuid_str in all_tokens and all_tokens[document_uuid_str] in metadata_by_token: + metadata = metadata_by_token[all_tokens[document_uuid_str]] + if metadata.get('name'): + file_name = metadata['name'] + + all_document_upload_paths[document_uuid_str] = generate_filename(obj, file_name, field.upload_to) + + # Make a copy of the documents and return the uuids of the copied documents + duplicates_documents_uuids = documents_remote_duplicate( + uuids=all_document_uuids, + with_modified_upload=True, + upload_path_by_uuid=all_document_upload_paths, + ) + + # Update the uuids of the documents with the uuids of the copied documents + for obj in objs: + for field_name in document_fields_by_obj_uuid[obj.uuid]: + setattr( + obj, + field_name, + [ + uuid.UUID(duplicates_documents_uuids[str(document_uuid)]) + for document_uuid in getattr(obj, field_name) + if duplicates_documents_uuids.get(str(document_uuid)) + ], + ) diff --git a/api/schema.py b/api/schema.py index 32b582997..c73a2efb3 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.112" +ADMISSION_SDK_VERSION = "1.0.114" 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 4e4db926a..791e19452 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 d187aed39..501dc2f81 100644 --- a/auth/predicates/doctorate.py +++ b/auth/predicates/doctorate.py @@ -68,6 +68,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): @@ -223,3 +224,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 6554cf3cd..df94ed2e6 100644 --- a/auth/roles/candidate.py +++ b/auth/roles/candidate.py @@ -63,7 +63,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 diff --git a/ddd/admission/doctorat/preparation/builder/proposition_builder.py b/ddd/admission/doctorat/preparation/builder/proposition_builder.py index 0c13d0111..7535b559b 100644 --- a/ddd/admission/doctorat/preparation/builder/proposition_builder.py +++ b/ddd/admission/doctorat/preparation/builder/proposition_builder.py @@ -28,7 +28,6 @@ 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._detail_projet import projet_non_rempli -from admission.ddd.admission.doctorat.preparation.domain.model.doctorat_formation import DoctoratFormation from admission.ddd.admission.doctorat.preparation.domain.model.enums import ( ChoixCommissionProximiteCDEouCLSM, ChoixCommissionProximiteCDSS, @@ -39,11 +38,12 @@ 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 @@ -60,9 +60,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': + 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', + ) -> 'Proposition': + doctorat = doctorat_translator.get(cmd.sigle_formation, cmd.annee_formation) InitierPropositionValidatorList( type_admission=cmd.type_admission, justification=cmd.justification, @@ -79,6 +100,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( entity_id=PropositionIdentityBuilder.build(), reference=reference, @@ -91,3 +113,73 @@ def initier_proposition( projet=projet_non_rempli, auteur_derniere_modification=cmd.matricule_candidat, ) + + @classmethod + def initier_nouvelle_proposition_attachee_a_pre_admission( + cls, + cmd: 'InitierPropositionCommand', + doctorat_translator: 'IDoctoratTranslator', + proposition_repository: 'IPropositionRepository', + ) -> 'Proposition': + pre_admission = proposition_repository.get(entity_id=PropositionIdentity(uuid=cmd.pre_admission_associee)) + + doctorat = doctorat_translator.get( + sigle=pre_admission.formation_id.sigle, annee=pre_admission.formation_id.annee + ) + + reference = proposition_repository.recuperer_reference_suivante() + + proposition = Proposition( + entity_id=PropositionIdentityBuilder.build(), + reference=reference, + statut=ChoixStatutPropositionDoctorale.EN_BROUILLON, + type_admission=ChoixTypeAdmission[cmd.type_admission], + formation_id=pre_admission.formation_id, + matricule_candidat=pre_admission.matricule_candidat, + projet=projet_non_rempli, + auteur_derniere_modification=cmd.matricule_candidat, + pre_admission_associee=pre_admission.entity_id, + curriculum=pre_admission.curriculum, + ) + + proposition.completer( + doctorat=doctorat, + justification=pre_admission.justification, + commission_proximite=pre_admission.commission_proximite.name if pre_admission.commission_proximite else '', + type_financement=pre_admission.financement.type.name if pre_admission.financement.type else '', + type_contrat_travail=pre_admission.financement.type_contrat_travail + if pre_admission.financement.type_contrat_travail + else '', + eft=pre_admission.financement.eft, + bourse_recherche=pre_admission.financement.bourse_recherche, + autre_bourse_recherche=pre_admission.financement.autre_bourse_recherche, + bourse_date_debut=pre_admission.financement.bourse_date_debut, + bourse_date_fin=pre_admission.financement.bourse_date_fin, + bourse_preuve=pre_admission.financement.bourse_preuve, + duree_prevue=pre_admission.financement.duree_prevue, + temps_consacre=pre_admission.financement.temps_consacre, + est_lie_fnrs_fria_fresh_csc=pre_admission.financement.est_lie_fnrs_fria_fresh_csc, + commentaire_financement=pre_admission.financement.commentaire, + langue_redaction_these=pre_admission.projet.langue_redaction_these, + institut_these=str(pre_admission.projet.institut_these.uuid) if pre_admission.projet.institut_these else '', + lieu_these=pre_admission.projet.lieu_these, + titre=pre_admission.projet.titre, + resume=pre_admission.projet.resume, + doctorat_deja_realise=pre_admission.experience_precedente_recherche.doctorat_deja_realise.name + if pre_admission.experience_precedente_recherche.doctorat_deja_realise + else '', + institution=pre_admission.experience_precedente_recherche.institution, + domaine_these=pre_admission.experience_precedente_recherche.domaine_these, + date_soutenance=pre_admission.experience_precedente_recherche.date_soutenance, + raison_non_soutenue=pre_admission.experience_precedente_recherche.raison_non_soutenue, + projet_doctoral_deja_commence=pre_admission.projet.deja_commence, + projet_doctoral_institution=pre_admission.projet.deja_commence_institution, + projet_doctoral_date_debut=pre_admission.projet.date_debut, + documents=pre_admission.projet.documents, + graphe_gantt=pre_admission.projet.graphe_gantt, + proposition_programme_doctoral=pre_admission.projet.proposition_programme_doctoral, + projet_formation_complementaire=pre_admission.projet.projet_formation_complementaire, + lettres_recommandation=pre_admission.projet.lettres_recommandation, + ) + + return proposition diff --git a/ddd/admission/doctorat/preparation/commands.py b/ddd/admission/doctorat/preparation/commands.py index 733d1c7f9..148d89939 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) @@ -775,3 +779,8 @@ class DemanderCandidatModificationCACommand(interface.CommandRequest): @attr.dataclass(frozen=True, slots=True) class SoumettreCACommand(interface.QueryRequest): uuid_proposition: str + + +@attr.dataclass(frozen=True, slots=True) +class ListerPreAdmissionsCandidatQuery(interface.QueryRequest): + matricule_candidat: str diff --git a/ddd/admission/doctorat/preparation/domain/model/proposition.py b/ddd/admission/doctorat/preparation/domain/model/proposition.py index ac6ca14bf..6d17e42a4 100644 --- a/ddd/admission/doctorat/preparation/domain/model/proposition.py +++ b/ddd/admission/doctorat/preparation/domain/model/proposition.py @@ -175,6 +175,8 @@ class Proposition(interface.RootEntity): profil_soumis_candidat: ProfilCandidat = None + pre_admission_associee: Optional[PropositionIdentity] = None + fiche_archive_signatures_envoyees: List[str] = attr.Factory(list) comptabilite: 'Comptabilite' = comptabilite_non_remplie reponses_questions_specifiques: Dict = attr.Factory(dict) diff --git a/ddd/admission/doctorat/preparation/domain/service/i_historique.py b/ddd/admission/doctorat/preparation/domain/service/i_historique.py index e67c77b22..b0960699c 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_groupe_de_supervision.py b/ddd/admission/doctorat/preparation/repository/i_groupe_de_supervision.py index 1207b5652..0b9f3bd82 100644 --- a/ddd/admission/doctorat/preparation/repository/i_groupe_de_supervision.py +++ b/ddd/admission/doctorat/preparation/repository/i_groupe_de_supervision.py @@ -24,9 +24,9 @@ # # ############################################################################## import abc +from abc import abstractmethod from typing import List, Optional, Union -from admission.models.enums.actor_type import ActorType from admission.ddd.admission.doctorat.preparation.domain.model._cotutelle import Cotutelle, pas_de_cotutelle from admission.ddd.admission.doctorat.preparation.domain.model.doctorat import DoctoratIdentity from admission.ddd.admission.doctorat.preparation.domain.model.groupe_de_supervision import ( @@ -34,8 +34,9 @@ GroupeDeSupervisionIdentity, SignataireIdentity, ) -from admission.ddd.admission.doctorat.preparation.domain.model.proposition import PropositionIdentity +from admission.ddd.admission.doctorat.preparation.domain.model.proposition import PropositionIdentity, Proposition from admission.ddd.admission.doctorat.preparation.dtos import CotutelleDTO, MembreCADTO, PromoteurDTO +from admission.models.enums.actor_type import ActorType from osis_common.ddd import interface from osis_common.ddd.interface import ApplicationService @@ -151,3 +152,12 @@ def get_cotutelle_dto_from_model(cls, cotutelle: Optional[Cotutelle]) -> 'Cotute convention=cotutelle and cotutelle.convention or [], autres_documents=cotutelle and cotutelle.autres_documents or [], ) + + @classmethod + @abstractmethod + def initialize_supervision_group_from_proposition( + cls, + uuid_proposition_originale: str, + nouvelle_proposition: 'Proposition', + ): + raise NotImplementedError diff --git a/ddd/admission/doctorat/preparation/repository/i_proposition.py b/ddd/admission/doctorat/preparation/repository/i_proposition.py index 6bb052dd9..dff9fab8b 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 @@ -85,7 +86,7 @@ def delete(cls, entity_id: 'PropositionIdentity', **kwargs: ApplicationService) @classmethod @abc.abstractmethod - def save(cls, entity: 'Proposition') -> None: # type: ignore[override] + def save(cls, entity: 'Proposition', dupliquer_documents=False) -> None: # type: ignore[override] raise NotImplementedError @classmethod 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..9c0854e63 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 @@ -28,6 +28,9 @@ 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.service.i_historique import IHistorique +from admission.ddd.admission.doctorat.preparation.repository.i_groupe_de_supervision import ( + IGroupeDeSupervisionRepository, +) from admission.ddd.admission.doctorat.preparation.repository.i_proposition import IPropositionRepository from admission.ddd.admission.domain.service.i_maximum_propositions import IMaximumPropositionsAutorisees @@ -38,20 +41,29 @@ def initier_proposition( doctorat_translator: 'IDoctoratTranslator', historique: 'IHistorique', maximum_propositions_service: 'IMaximumPropositionsAutorisees', + groupe_supervision_repository: 'IGroupeDeSupervisionRepository', ) -> '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( cmd, - doctorat, + doctorat_translator, proposition_repository, ) # THEN - proposition_repository.save(proposition) - historique.historiser_initiation(proposition) + proposition_repository.save( + proposition, + dupliquer_documents=bool(cmd.pre_admission_associee), + ) + + groupe_supervision_repository.initialize_supervision_group_from_proposition( + uuid_proposition_originale=cmd.pre_admission_associee, + nouvelle_proposition=proposition, + ) + + historique.historiser_initiation(proposition.entity_id, cmd.matricule_candidat) return proposition.entity_id diff --git a/infrastructure/admission/doctorat/preparation/domain/service/historique.py b/infrastructure/admission/doctorat/preparation/domain/service/historique.py index 2e93b7cca..99fce3cf5 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 "approuvé", 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 1d51918dc..68b706f24 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/domain/service/membre_CA.py b/infrastructure/admission/doctorat/preparation/domain/service/membre_CA.py index 131bebdbc..ff78d505c 100644 --- a/infrastructure/admission/doctorat/preparation/domain/service/membre_CA.py +++ b/infrastructure/admission/doctorat/preparation/domain/service/membre_CA.py @@ -45,7 +45,7 @@ def get(cls, membre_ca_id: 'MembreCAIdentity') -> 'MembreCAIdentity': @classmethod def get_dto(cls, membre_ca_id: 'MembreCAIdentity') -> MembreCADTO: - actor = SupervisionActor.objects.select_related('person').get( + actor = SupervisionActor.objects.select_related('person__tutor', 'country').get( type=ActorType.CA_MEMBER.name, uuid=membre_ca_id.uuid, ) diff --git a/infrastructure/admission/doctorat/preparation/handlers.py b/infrastructure/admission/doctorat/preparation/handlers.py index 2342a1aad..069c62e14 100644 --- a/infrastructure/admission/doctorat/preparation/handlers.py +++ b/infrastructure/admission/doctorat/preparation/handlers.py @@ -87,6 +87,7 @@ doctorat_translator=DoctoratTranslator(), historique=Historique(), maximum_propositions_service=MaximumPropositionsAutorisees(), + groupe_supervision_repository=GroupeDeSupervisionRepository(), ), 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 5f027a2dd..30adf39e8 100644 --- a/infrastructure/admission/doctorat/preparation/handlers_in_memory.py +++ b/infrastructure/admission/doctorat/preparation/handlers_in_memory.py @@ -127,6 +127,7 @@ doctorat_translator=_doctorat_translator, historique=_historique, maximum_propositions_service=_maximum_propositions_autorisees, + groupe_supervision_repository=_groupe_supervision_repository, ), CompleterPropositionCommand: lambda msg_bus, cmd: completer_proposition( cmd, diff --git a/infrastructure/admission/doctorat/preparation/repository/groupe_de_supervision.py b/infrastructure/admission/doctorat/preparation/repository/groupe_de_supervision.py index a7686d1f1..3ed747b22 100644 --- a/infrastructure/admission/doctorat/preparation/repository/groupe_de_supervision.py +++ b/infrastructure/admission/doctorat/preparation/repository/groupe_de_supervision.py @@ -30,13 +30,12 @@ from django.db.models.functions import Coalesce from django.utils.translation import get_language, gettext_lazy as _ from osis_signature.enums import SignatureState +from osis_signature.models import Actor, Process, StateHistory from admission.auth.roles.ca_member import CommitteeMember from admission.auth.roles.promoter import Promoter -from admission.models import DoctorateAdmission, SupervisionActor -from admission.models.enums.actor_type import ActorType from admission.ddd.admission.doctorat.preparation.builder.proposition_identity_builder import PropositionIdentityBuilder -from admission.ddd.admission.doctorat.preparation.domain.model._cotutelle import Cotutelle, pas_de_cotutelle +from admission.ddd.admission.doctorat.preparation.domain.model._cotutelle import Cotutelle from admission.ddd.admission.doctorat.preparation.domain.model._membre_CA import MembreCAIdentity from admission.ddd.admission.doctorat.preparation.domain.model._promoteur import PromoteurIdentity from admission.ddd.admission.doctorat.preparation.domain.model._signature_membre_CA import SignatureMembreCA @@ -54,14 +53,16 @@ GroupeDeSupervisionIdentity, SignataireIdentity, ) +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.dtos import CotutelleDTO, MembreCADTO, PromoteurDTO from admission.ddd.admission.doctorat.preparation.repository.i_groupe_de_supervision import ( IGroupeDeSupervisionRepository, ) +from admission.models import DoctorateAdmission, SupervisionActor +from admission.models.enums.actor_type import ActorType from base.models.person import Person from osis_role.contrib.permissions import _get_roles_assigned_to_user -from osis_signature.models import Actor, Process, StateHistory from reference.models.country import Country @@ -326,7 +327,7 @@ def remove_member(cls, groupe_id: 'GroupeDeSupervisionIdentity', signataire: 'Si @classmethod def get_members(cls, groupe_id: 'GroupeDeSupervisionIdentity') -> List[Union['PromoteurDTO', 'MembreCADTO']]: - actors = SupervisionActor.objects.select_related('person__tutor').filter( + actors = SupervisionActor.objects.select_related('person__tutor', 'country').filter( process__uuid=groupe_id.uuid, ) members = [] @@ -374,3 +375,45 @@ def edit_external_member( country=Country.objects.get(iso_code=country_code) if country_code else None, language=language, ) + + @classmethod + def initialize_supervision_group_from_proposition( + cls, + uuid_proposition_originale: str, + nouvelle_proposition: 'Proposition', + ): + proposition = DoctorateAdmission.objects.select_related('supervision_group').get( + uuid=nouvelle_proposition.entity_id.uuid + ) + + # Create a supervision group if necessary + if not proposition.supervision_group_id: + proposition.supervision_group = Process.objects.create() + proposition.save(update_fields=['supervision_group']) + + if not uuid_proposition_originale: + return + + # Copy members of the supervision group of the original proposition + for admission_actor in SupervisionActor.objects.filter( + process__doctorateadmission__uuid=uuid_proposition_originale, + ): + SupervisionActor.objects.create( + process=proposition.supervision_group, + type=admission_actor.type, + is_doctor=admission_actor.is_doctor, + is_reference_promoter=admission_actor.is_reference_promoter, + **( + {'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, + } + ), + ) diff --git a/infrastructure/admission/doctorat/preparation/repository/in_memory/groupe_de_supervision.py b/infrastructure/admission/doctorat/preparation/repository/in_memory/groupe_de_supervision.py index 616975ed6..e60a7f0d9 100644 --- a/infrastructure/admission/doctorat/preparation/repository/in_memory/groupe_de_supervision.py +++ b/infrastructure/admission/doctorat/preparation/repository/in_memory/groupe_de_supervision.py @@ -27,22 +27,23 @@ import uuid from typing import List, Optional, Union -from admission.models.enums.actor_type import ActorType from admission.ddd.admission.doctorat.preparation.builder.proposition_identity_builder import PropositionIdentityBuilder from admission.ddd.admission.doctorat.preparation.domain.model._cotutelle import pas_de_cotutelle from admission.ddd.admission.doctorat.preparation.domain.model._membre_CA import MembreCAIdentity from admission.ddd.admission.doctorat.preparation.domain.model._promoteur import PromoteurIdentity from admission.ddd.admission.doctorat.preparation.domain.model._signature_membre_CA import SignatureMembreCA from admission.ddd.admission.doctorat.preparation.domain.model._signature_promoteur import SignaturePromoteur -from admission.ddd.admission.doctorat.preparation.domain.model.enums import ChoixStatutPropositionDoctorale, \ - ChoixEtatSignature from admission.ddd.admission.doctorat.preparation.domain.model.doctorat import DoctoratIdentity +from admission.ddd.admission.doctorat.preparation.domain.model.enums import ( + ChoixStatutPropositionDoctorale, + ChoixEtatSignature, +) from admission.ddd.admission.doctorat.preparation.domain.model.groupe_de_supervision import ( GroupeDeSupervision, GroupeDeSupervisionIdentity, SignataireIdentity, ) -from admission.ddd.admission.doctorat.preparation.domain.model.proposition import PropositionIdentity +from admission.ddd.admission.doctorat.preparation.domain.model.proposition import PropositionIdentity, Proposition from admission.ddd.admission.doctorat.preparation.domain.validator.exceptions import ( GroupeDeSupervisionNonTrouveException, ) @@ -78,6 +79,7 @@ Promoteur, PromoteurInMemoryTranslator, ) +from admission.models.enums.actor_type import ActorType from base.ddd.utils.in_memory_repository import InMemoryGenericRepository @@ -245,3 +247,47 @@ def edit_external_member( membre.pays = country_code membre.langue = language return + + @classmethod + def initialize_supervision_group_from_proposition( + cls, + uuid_proposition_originale: str, + nouvelle_proposition: 'Proposition', + ): + try: + nouveau_groupe_supervision = cls.get_by_proposition_id(nouvelle_proposition.entity_id) + except GroupeDeSupervisionNonTrouveException: + nouveau_groupe_supervision = GroupeDeSupervision( + entity_id=GroupeDeSupervisionIdentity(uuid=str(uuid.uuid4())), + proposition_id=nouvelle_proposition.entity_id, + ) + + cls.save(nouveau_groupe_supervision) + + if not uuid_proposition_originale: + return + + try: + groupe_supervision_original = cls.get_by_proposition_id( + proposition_id=PropositionIdentity(uuid=str(uuid_proposition_originale)), + ) + + membres = cls.get_members(groupe_id=groupe_supervision_original.entity_id) + + except GroupeDeSupervisionNonTrouveException: + return + + for member in membres: + cls.add_member( + groupe_id=nouveau_groupe_supervision.entity_id, + proposition_status=nouvelle_proposition.statut, + type=ActorType.PROMOTER if isinstance(member, PromoteurDTO) else ActorType.CA_MEMBER, + matricule=member.matricule, + first_name=member.prenom, + last_name=member.nom, + email=member.email, + is_doctor=member.est_docteur, + institute=member.institution, + city=member.ville, + country_code=member.code_pays, + ) diff --git a/infrastructure/admission/doctorat/preparation/repository/in_memory/proposition.py b/infrastructure/admission/doctorat/preparation/repository/in_memory/proposition.py index 25b485168..0d502b713 100644 --- a/infrastructure/admission/doctorat/preparation/repository/in_memory/proposition.py +++ b/infrastructure/admission/doctorat/preparation/repository/in_memory/proposition.py @@ -151,7 +151,7 @@ def reset(cls): ] @classmethod - def save(cls, entity: 'Proposition') -> None: + def save(cls, entity: 'Proposition', dupliquer_documents=False) -> None: super().save(entity) @classmethod @@ -186,6 +186,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: @@ -296,19 +297,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 ), @@ -327,6 +330,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 883ed2df8..4c99068c5 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, @@ -188,6 +190,9 @@ def _instantiate_admission(admission: 'DoctorateAdmission') -> 'Proposition': ), creee_le=admission.created_at, modifiee_le=admission.modified_at, + pre_admission_associee=PropositionIdentityBuilder.build_from_uuid( + str(admission.related_pre_admission.uuid) + ) if admission.related_pre_admission_id else None, soumise_le=admission.submitted_at, comptabilite=get_accounting_from_admission(admission=admission), reponses_questions_specifiques=admission.specific_question_answers, @@ -196,30 +201,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 +257,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, @@ -316,7 +333,7 @@ def delete(cls, entity_id: 'PropositionIdentity', **kwargs: ApplicationService) raise NotImplementedError @classmethod - def save(cls, entity: 'Proposition') -> None: + def save(cls, entity: 'Proposition', dupliquer_documents=False) -> None: doctorate = EducationGroupYear.objects.get( acronym=entity.sigle_formation, academic_year__year=entity.annee, @@ -332,6 +349,7 @@ def save(cls, entity: 'Proposition') -> None: entity.auteur_derniere_modification, entity.financabilite_derogation_premiere_notification_par, entity.financabilite_derogation_derniere_notification_par, + entity.financabilite_etabli_par, ] if matricule ] @@ -356,11 +374,17 @@ def save(cls, entity: 'Proposition') -> None: else None ) - financabilite_etabli_par_person = None - if entity.financabilite_etabli_par: - financabilite_etabli_par_person = Person.objects.filter( - global_id=entity.financabilite_etabli_par, - ).first() + financabilite_etabli_par_person = ( + persons[entity.financabilite_etabli_par] + if entity.financabilite_etabli_par in persons + else None + ) + + related_pre_admission_id = None + if entity.pre_admission_associee: + related_pre_admission_id = DoctorateAdmission.objects.only('pk').get( + uuid=entity.pre_admission_associee.uuid, + ).pk years = [year for year in [entity.annee_calculee, entity.millesime_condition_acces] if year] academic_years = {} @@ -371,6 +395,7 @@ def save(cls, entity: 'Proposition') -> None: admission, _ = DoctorateAdmission.objects.update_or_create( uuid=entity.entity_id.uuid, defaults={ + 'duplicate_documents_when_saving': dupliquer_documents, # Indicate if the documents must be duplicated # FIXME remove when upgrading to Django 5.2? https://code.djangoproject.com/ticket/35890 'modified_at': timezone.now(), 'reference': entity.reference, @@ -379,6 +404,7 @@ def save(cls, entity: 'Proposition') -> None: 'comment': entity.justification, 'candidate': candidate, 'submitted_at': entity.soumise_le, + 'related_pre_admission_id': related_pre_admission_id, 'proximity_commission': entity.commission_proximite and entity.commission_proximite.name or '', 'doctorate': doctorate, 'determined_academic_year': academic_years.get(entity.annee_calculee), @@ -437,19 +463,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 +502,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 +557,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 +584,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 +612,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 +652,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 +699,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 +719,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, @@ -706,9 +757,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, @@ -757,9 +810,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=( @@ -819,29 +872,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() @@ -858,9 +916,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 2fd68e1d5..e3ed6c20d 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -5270,6 +5270,9 @@ msgstr "" msgid "Related experience" msgstr "" +msgid "Related pre-admission" +msgstr "" + msgid "Relationship" msgstr "" @@ -6044,6 +6047,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 None: + if self._state.adding and self.duplicate_documents_when_saving: + copy_documents(objs=[self]) + super().save(*args, **kwargs) cache.delete('admission_permission_{}'.format(self.uuid)) @@ -776,6 +802,7 @@ def get_queryset(self): "financability_established_by", "financability_dispensation_first_notification_by", "financability_dispensation_last_notification_by", + "related_pre_admission", ) .annotate( code_secteur_formation=CTESubquery(sector_subqs.values("acronym")[:1]), diff --git a/schema.yml b/schema.yml index 6e5df4e73..6b074b886 100644 --- a/schema.yml +++ b/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: Admission API - version: 1.0.112 + version: 1.0.114 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 @@ -5310,6 +5337,8 @@ components: properties: uuid: type: string + pre_admission_associee: + type: string reference: type: string type_admission: @@ -5825,6 +5854,8 @@ components: properties: uuid: type: string + pre_admission_associee: + type: string reference: type: string type_admission: @@ -6625,11 +6656,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: @@ -8789,11 +8870,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 @@ -8819,8 +8903,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 2073c23b4..f1a95f646 100644 --- a/tests/api/views/test_signatures.py +++ b/tests/api/views/test_signatures.py @@ -159,9 +159,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..5c2278668 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): @@ -106,13 +119,14 @@ def setUpTestData(cls): cls.commission = EntityVersionFactory( parent=cls.sector, entity_type=EntityType.DOCTORAL_COMMISSION.name, - acronym='CDA', + acronym='CDSS', ).entity cls.doctorate = DoctorateFactory( management_entity=cls.commission, enrollment_campus__name='Mons', ) cls.scholarship = ErasmusMundusScholarshipFactory() + cls.language = LanguageFactory(code='EN') AdmissionAcademicCalendarFactory.produce_all_required() cls.create_data = { @@ -121,11 +135,78 @@ def setUpTestData(cls): "sigle_formation": cls.doctorate.acronym, "annee_formation": cls.doctorate.academic_year.year, "matricule_candidat": cls.candidate.global_id, - "commission_proximite": '', + "commission_proximite": ChoixCommissionProximiteCDSS.ECLI.name, } 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) @@ -150,7 +231,204 @@ def test_admission_doctorate_creation_using_api_candidate(self): admission.reference, seq_value + 1, ) - self.assertEqual(response.json()['doctorate_propositions'][0]["reference"], f'M-CDA22-{str(admission)}') + self.assertEqual(response.json()['doctorate_propositions'][0]["reference"], f'M-CDSS22-{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.WORK_CONTRACT.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, pre_admission.candidate) + self.assertEqual(new_admission.training, pre_admission.training) + self.assertEqual(new_admission.type, ChoixTypeAdmission.ADMISSION.name) + self.assertEqual(new_admission.status, ChoixStatutPropositionDoctorale.EN_BROUILLON.name) + self.assertEqual(new_admission.comment, pre_admission.comment) + self.assertEqual(new_admission.proximity_commission, pre_admission.proximity_commission) + self.assertEqual(new_admission.financing_type, pre_admission.financing_type) + self.assertEqual(new_admission.financing_work_contract, pre_admission.financing_work_contract) + self.assertEqual(new_admission.financing_eft, pre_admission.financing_eft) + self.assertEqual(new_admission.international_scholarship_id, pre_admission.international_scholarship_id) + self.assertEqual(new_admission.other_international_scholarship, pre_admission.other_international_scholarship) + self.assertEqual(new_admission.scholarship_start_date, pre_admission.scholarship_start_date) + self.assertEqual(new_admission.scholarship_end_date, pre_admission.scholarship_end_date) + self.assertEqual(new_admission.scholarship_proof, self.duplicated_documents_tokens['scholarship_proof']) + self.assertEqual(new_admission.planned_duration, pre_admission.planned_duration) + self.assertEqual(new_admission.dedicated_time, pre_admission.dedicated_time) + self.assertEqual(new_admission.is_fnrs_fria_fresh_csc_linked, pre_admission.is_fnrs_fria_fresh_csc_linked) + self.assertEqual(new_admission.financing_comment, pre_admission.financing_comment) + self.assertEqual(new_admission.project_title, pre_admission.project_title) + self.assertEqual(new_admission.project_abstract, pre_admission.project_abstract) + self.assertEqual(new_admission.thesis_language, pre_admission.thesis_language) + self.assertEqual(new_admission.thesis_institute, pre_admission.thesis_institute) + self.assertEqual(new_admission.thesis_location, pre_admission.thesis_location) + self.assertEqual(new_admission.phd_alread_started, pre_admission.phd_alread_started) + self.assertEqual(new_admission.phd_alread_started_institute, pre_admission.phd_alread_started_institute) + self.assertEqual(new_admission.work_start_date, pre_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, pre_admission.phd_already_done) + self.assertEqual(new_admission.phd_already_done_institution, pre_admission.phd_already_done_institution) + self.assertEqual(new_admission.phd_already_done_thesis_domain, pre_admission.phd_already_done_thesis_domain) + self.assertEqual(new_admission.phd_already_done_defense_date, pre_admission.phd_already_done_defense_date) + self.assertEqual( + new_admission.phd_already_done_no_defense_reason, + pre_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) @@ -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 75f8ebff4..dcf4b4986 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, diff --git a/tests/utils/test_copy_documents.py b/tests/utils/test_copy_documents.py index 445f67566..e0a6b0b20 100644 --- a/tests/utils/test_copy_documents.py +++ b/tests/utils/test_copy_documents.py @@ -28,8 +28,8 @@ from django.test import TestCase +from admission.admission_utils.copy_documents import copy_documents from admission.tests.factories.curriculum import EducationalExperienceFactory, ProfessionalExperienceFactory -from admission.utils import copy_documents from base.tests.factories.person import PersonFactory diff --git a/utils.py b/utils.py index 8f1a518de..4991d6db9 100644 --- a/utils.py +++ b/utils.py @@ -440,74 +440,6 @@ def get_access_titles_names( return access_titles_names -def copy_documents(objs): - """ - Create copies of the files of the specified objects and affect them to the specified objects. - :param objs: The list of objects. - """ - from osis_document.api.utils import get_several_remote_metadata, get_remote_tokens, documents_remote_duplicate - from osis_document.contrib import FileField - from osis_document.utils import generate_filename - - all_document_uuids = [] - all_document_upload_paths = {} - document_fields_by_obj_uuid = {} - - # Get all the document fields and the uuids of the documents to duplicate - for obj in objs: - document_fields_by_obj_uuid[obj.uuid] = {} - - for field in obj._meta.get_fields(): - if isinstance(field, FileField): - document_uuids = getattr(obj, field.name) - - if document_uuids: - document_fields_by_obj_uuid[obj.uuid][field.name] = field - all_document_uuids += [document_uuid for document_uuid in document_uuids if document_uuid] - - all_tokens = get_remote_tokens(all_document_uuids) - metadata_by_token = get_several_remote_metadata(tokens=list(all_tokens.values())) - - # Get the upload paths of the documents to duplicate - for obj in objs: - for field_name, field in document_fields_by_obj_uuid[obj.uuid].items(): - document_uuids = getattr(obj, field_name) - - for document_uuid in document_uuids: - if not document_uuid: - continue - - document_uuid_str = str(document_uuid) - file_name = 'file' - - if document_uuid_str in all_tokens and all_tokens[document_uuid_str] in metadata_by_token: - metadata = metadata_by_token[all_tokens[document_uuid_str]] - if metadata.get('name'): - file_name = metadata['name'] - - all_document_upload_paths[document_uuid_str] = generate_filename(obj, file_name, field.upload_to) - - # Make a copy of the documents and return the uuids of the copied documents - duplicates_documents_uuids = documents_remote_duplicate( - uuids=all_document_uuids, - with_modified_upload=True, - upload_path_by_uuid=all_document_upload_paths, - ) - - # Update the uuids of the documents with the uuids of the copied documents - for obj in objs: - for field_name in document_fields_by_obj_uuid[obj.uuid]: - setattr( - obj, - field_name, - [ - uuid.UUID(duplicates_documents_uuids[str(document_uuid)]) - for document_uuid in getattr(obj, field_name) - if duplicates_documents_uuids.get(str(document_uuid)) - ], - ) - - def get_experience_urls( user: User, admission: Union[DoctorateAdmission, GeneralEducationAdmission, ContinuingEducationAdmission], diff --git a/views/common/form_tabs/curriculum.py b/views/common/form_tabs/curriculum.py index 7fa5db144..2fe3e7e66 100644 --- a/views/common/form_tabs/curriculum.py +++ b/views/common/form_tabs/curriculum.py @@ -37,6 +37,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView +from admission.admission_utils.copy_documents import copy_documents from admission.ddd.admission.formation_generale.domain.service.checklist import Checklist from admission.models import EPCInjection as AdmissionEPCInjection from admission.models.base import ( @@ -46,7 +47,6 @@ from admission.models.base import BaseAdmission from admission.models.checklist import FreeAdditionalApprovalCondition from admission.models.epc_injection import EPCInjectionType, EPCInjectionStatus as AdmissionEPCInjectionStatus -from admission.utils import copy_documents from admission.views.common.mixins import AdmissionFormMixin, LoadDossierViewMixin from osis_profile.models import ProfessionalExperience, EducationalExperience, EducationalExperienceYear from osis_profile.models.epc_injection import (