diff --git a/admin.py b/admin.py index 1ef772bc7..3ec7f961a 100644 --- a/admin.py +++ b/admin.py @@ -30,14 +30,7 @@ class DoctorateAdmissionAdmin(admin.ModelAdmin): - def save_form(self, request, form, change): - """ - Set the author if the admission doctorate is being created - """ - admission_doctorate = form.save(commit=False) - if not change: - admission_doctorate.author = request.user.person - return admission_doctorate + pass admin.site.register(DoctorateAdmission, DoctorateAdmissionAdmin) diff --git a/api/generator.py b/api/generator.py new file mode 100644 index 000000000..5d3ce2fc1 --- /dev/null +++ b/api/generator.py @@ -0,0 +1,248 @@ +# ############################################################################## +# +# 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-2021 Université catholique de Louvain (http://www.uclouvain.be) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# A copy of this license - GNU General Public License - is available +# at the root of the source code of this program. If not, +# see http://www.gnu.org/licenses/. +# +# ############################################################################## +from collections import OrderedDict + +from rest_framework.schemas.openapi import AutoSchema, SchemaGenerator +from rest_framework.serializers import Serializer + +from base.models.utils.utils import ChoiceEnum + + +class AdmissionSchemaGenerator(SchemaGenerator): + def get_schema(self, *args, **kwargs): + schema = super().get_schema(*args, **kwargs) + schema["openapi"] = "3.0.0" + schema["info"]["title"] = "Admission API" + schema["info"]["description"] = "This API delivers data for the Admission project." + schema["info"]["contact"] = { + "name": "UCLouvain - OSIS", + "url": "https://github.com/uclouvain/osis" + } + schema["servers"] = [ + { + "url": "https://{environment}.osis.uclouvain.be/api/v1/admission/", + "variables": { + "environment": { + "default": "dev", + "enum": [ + "dev", + "qa", + "test" + ] + } + } + }, + { + "url": "https://osis.uclouvain.be/api/v1/admission/", + "description": "Production server" + } + ] + schema["security"] = [{"Token": []}] + schema['components']["securitySchemes"] = { + "Token": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "Enter your token in the format **Token <token>**" + } + } + schema['components']['parameters'] = { + "X-User-FirstName": { + "in": "header", + "name": "X-User-FirstName", + "schema": { + "type": "string" + }, + "required": False + }, + "X-User-LastName": { + "in": "header", + "name": "X-User-LastName", + "schema": { + "type": "string" + }, + "required": False + }, + "X-User-Email": { + "in": "header", + "name": "X-User-Email", + "schema": { + "type": "string" + }, + "required": False + }, + "X-User-GlobalID": { + "in": "header", + "name": "X-User-GlobalID", + "schema": { + "type": "string" + }, + "required": False + }, + "Accept-Language": { + "in": "header", + "name": "Accept-Language", + "description": "The header advertises which languages the client is able to understand, and which " + "locale variant is preferred. (By languages, we mean natural languages, such as " + "English, and not programming languages.)", + "schema": { + "$ref": "#/components/schemas/AcceptedLanguageEnum" + }, + "required": False + } + } + schema['components']['responses'] = { + "Unauthorized": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "BadRequest": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "NotFound": { + "description": "The specified resource was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + schema['components']['schemas']['Error'] = { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + } + schema['components']['schemas']['AcceptedLanguageEnum'] = { + "type": "string", + "enum": [ + "en", + "fr-be" + ] + } + for path, path_content in schema['paths'].items(): + for method, method_content in path_content.items(): + method_content['parameters'].extend([ + {'$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'}, + ]) + method_content['responses'].update({ + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }) + return schema + + +class DetailedAutoSchema(AutoSchema): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.enums = {} + + def get_request_body(self, path, method): + if method not in ('PUT', 'PATCH', 'POST'): + return {} + + self.request_media_types = self.map_parsers(path, method) + + serializer = self.get_serializer(path, method, for_response=False) + + if not isinstance(serializer, Serializer): + item_schema = {} + else: + item_schema = self._get_reference(serializer) + + return { + 'content': { + ct: {'schema': item_schema} + for ct in self.request_media_types + } + } + + def get_components(self, path, method): + if method.lower() == 'delete': + return {} + + components = {} + for with_response in [True, False]: + serializer = self.get_serializer(path, method, for_response=with_response) + if not isinstance(serializer, Serializer): + return {} + component_name = self.get_component_name(serializer) + content = self.map_serializer(serializer) + components[component_name] = content + + for enum_name, enum in self.enums.items(): + components[enum_name] = enum + + return components + + def get_serializer(self, path, method, for_response=True): + raise NotImplementedError + + def map_choicefield(self, field): + # The only way to retrieve the original enum is to compare choices + for declared_enum in ChoiceEnum.__subclasses__(): + if OrderedDict(declared_enum.choices()) == field.choices: + self.enums[declared_enum.__name__] = super().map_choicefield(field) + return { + '$ref': "#/components/responses/{}".format(declared_enum.__name__) + } + return super().map_choicefield(field) diff --git a/api/urls_v1.py b/api/url_v1.py similarity index 76% rename from api/urls_v1.py rename to api/url_v1.py index ed2a0caa7..9bb197d2d 100644 --- a/api/urls_v1.py +++ b/api/url_v1.py @@ -23,12 +23,14 @@ # see http://www.gnu.org/licenses/. # # ############################################################################## +from django.urls import path -from rest_framework.routers import DefaultRouter - -from admission.api.views import DoctorateAdmissionViewSet +from admission.api import views app_name = "admission_api_v1" -router = DefaultRouter() -router.register(r'', DoctorateAdmissionViewSet, basename='doctorate') -urlpatterns = router.urls +urlpatterns = [ + path('propositions', views.PropositionListViewSet.as_view()), + path('propositions/', views.PropositionViewSet.as_view()), + path('autocomplete/sector', views.AutocompleteSectorViewSet.as_view()), + path('autocomplete/sector//doctorates', views.AutocompleteDoctoratViewSet.as_view()), +] diff --git a/api/views/__init__.py b/api/views/__init__.py index fc4924da8..220361fd2 100644 --- a/api/views/__init__.py +++ b/api/views/__init__.py @@ -24,8 +24,12 @@ # # ############################################################################## -from .doctorate import DoctorateAdmissionViewSet +from admission.api.views.doctorate import * +from admission.api.views.autocomplete import * __all__ = [ - "DoctorateAdmissionViewSet", + "PropositionViewSet", + "PropositionListViewSet", + "AutocompleteDoctoratViewSet", + "AutocompleteSectorViewSet", ] diff --git a/api/views/autocomplete.py b/api/views/autocomplete.py new file mode 100644 index 000000000..d82b0fb3f --- /dev/null +++ b/api/views/autocomplete.py @@ -0,0 +1,65 @@ +# ############################################################################## +# +# 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-2021 Université catholique de Louvain (http://www.uclouvain.be) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# A copy of this license - GNU General Public License - is available +# at the root of the source code of this program. If not, +# see http://www.gnu.org/licenses/. +# +# ############################################################################## +from django.db.models import F +from rest_framework.generics import ListAPIView +from rest_framework.response import Response + +from admission.contrib import serializers +from admission.contrib.models import EntityProxy +from base.models.enums.entity_type import SECTOR +from ddd.logic.admission.preparation.projet_doctoral.commands import SearchDoctoratCommand +from infrastructure.messages_bus import message_bus_instance + + +class AutocompleteSectorViewSet(ListAPIView): + """Autocomplete sectors""" + pagination_class = None + filter_backends = None + serializer_class = serializers.SectorDTOSerializer + + def list(self, request, **kwargs): + # TODO revert to command once it's in the shared kernel + qs = EntityProxy.objects.with_acronym().with_title().with_type().filter(type=SECTOR).annotate( + sigle=F('acronym'), + intitule_fr=F('title'), + intitule_en=F('title'), + ).values('sigle', 'intitule_fr', 'intitule_en') + serializer = serializers.SectorDTOSerializer(instance=qs, many=True) + return Response(serializer.data) + + +class AutocompleteDoctoratViewSet(ListAPIView): + """Autocomplete doctorates given a sector""" + pagination_class = None + filter_backends = None + serializer_class = serializers.DoctoratDTOSerializer + + def list(self, request, **kwargs): + doctorat_list = message_bus_instance.invoke( + SearchDoctoratCommand(sigle_secteur_entite_gestion=kwargs.get('sigle')) + ) + serializer = serializers.DoctoratDTOSerializer(instance=doctorat_list, many=True) + return Response(serializer.data) diff --git a/api/views/doctorate.py b/api/views/doctorate.py index fd0f56761..749bcdaf5 100644 --- a/api/views/doctorate.py +++ b/api/views/doctorate.py @@ -23,33 +23,96 @@ # see http://www.gnu.org/licenses/. # # ############################################################################## - -from rest_framework import viewsets, status -from rest_framework.authentication import SessionAuthentication +from rest_framework import mixins, status +from rest_framework.generics import GenericAPIView, ListCreateAPIView from rest_framework.response import Response -from admission.contrib.models import DoctorateAdmission -from admission.contrib.serializers import ( - DoctorateAdmissionReadSerializer, DoctorateAdmissionWriteSerializer +from admission.api.generator import DetailedAutoSchema +from admission.contrib import serializers +from backoffice.settings.rest_framework.common_views import DisplayExceptionsByFieldNameAPIMixin +from ddd.logic.admission.preparation.projet_doctoral.commands import ( + CompleterPropositionCommand, GetPropositionCommand, + InitierPropositionCommand, + SearchPropositionsCommand, +) +from ddd.logic.admission.preparation.projet_doctoral.domain.validator.exceptions import ( + BureauCDEInconsistantException, + ContratTravailInconsistantException, + InstitutionInconsistanteException, + JustificationRequiseException, ) +from infrastructure.messages_bus import message_bus_instance + + +class PropositionListSchema(DetailedAutoSchema): + def get_operation_id_base(self, path, method, action): + return '_proposition' if method == 'POST' else '_propositions' + def get_serializer(self, path, method, for_response=True): + if method == 'POST': + if for_response: + return serializers.PropositionIdentityDTOSerializer() + return serializers.InitierPropositionCommandSerializer() + return serializers.PropositionSearchDTOSerializer() -class DoctorateAdmissionViewSet(viewsets.ModelViewSet): - queryset = DoctorateAdmission.objects.all() - authentication_classes = [SessionAuthentication, ] - lookup_field = "uuid" - def get_serializer_class(self): - if self.action in ["create", "update", "partial_update"]: - return DoctorateAdmissionWriteSerializer - return DoctorateAdmissionReadSerializer +class PropositionListViewSet(DisplayExceptionsByFieldNameAPIMixin, ListCreateAPIView): + schema = PropositionListSchema() + pagination_class = None + filter_backends = None + field_name_by_exception = { + JustificationRequiseException: ['justification'], + InstitutionInconsistanteException: ['institution'], + ContratTravailInconsistantException: ['type_contrat_travail'], + BureauCDEInconsistantException: ['bureau_cde'], + } - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + def list(self, request, **kwargs): + """List the propositions of the logged in user""" + proposition_list = message_bus_instance.invoke( + SearchPropositionsCommand(matricule_candidat=request.user.person.global_id) + ) + serializer = serializers.PropositionSearchDTOSerializer(instance=proposition_list, many=True) + return Response(serializer.data) + + def create(self, request, **kwargs): + """Create a new proposition""" + serializer = serializers.InitierPropositionCommandSerializer(data=request.data) serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - serializer = DoctorateAdmissionReadSerializer(instance=serializer.instance) - headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, status=status.HTTP_201_CREATED, headers=headers + result = message_bus_instance.invoke(InitierPropositionCommand(**serializer.data)) + serializer = serializers.PropositionIdentityDTOSerializer(instance=result) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class PropositionSchema(DetailedAutoSchema): + def get_operation_id_base(self, path, method, action): + return '_proposition' + + def get_serializer(self, path, method, for_response=True): + if method == 'PUT': + if for_response: + return serializers.PropositionIdentityDTOSerializer() + return serializers.CompleterPropositionCommandSerializer() + return serializers.PropositionDTOSerializer() + + +class PropositionViewSet(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, GenericAPIView): + schema = PropositionSchema() + pagination_class = None + filter_backends = None + + def get(self, request, *args, **kwargs): + """Get a single proposition""" + # TODO call osis_role perm for this object + proposition = message_bus_instance.invoke( + GetPropositionCommand(uuid_proposition=kwargs.get('uuid')) ) + serializer = serializers.PropositionDTOSerializer(instance=proposition) + return Response(serializer.data) + + def put(self, request, *args, **kwargs): + serializer = serializers.CompleterPropositionCommandSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + result = message_bus_instance.invoke(CompleterPropositionCommand(**serializer.data)) + serializer = serializers.PropositionIdentityDTOSerializer(instance=result) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/auth/predicates.py b/auth/predicates.py index 3f7b9b551..8d9245e79 100644 --- a/auth/predicates.py +++ b/auth/predicates.py @@ -32,7 +32,7 @@ @predicate def is_admission_request_author(user: User, obj: BaseAdmission): - return obj.author == user.person + return obj.candidate == user.person @predicate diff --git a/contrib/forms/__init__.py b/contrib/forms/__init__.py index 2fa2b6da8..219501700 100644 --- a/contrib/forms/__init__.py +++ b/contrib/forms/__init__.py @@ -24,8 +24,8 @@ # # ############################################################################## -from .doctorate import DoctorateAdmissionCreateOrUpdateForm +from .doctorate import DoctorateAdmissionProjectForm __all__ = [ - "DoctorateAdmissionCreateOrUpdateForm", + "DoctorateAdmissionProjectForm", ] diff --git a/contrib/forms/doctorate.py b/contrib/forms/doctorate.py index 395564872..fc1d0dcfa 100644 --- a/contrib/forms/doctorate.py +++ b/contrib/forms/doctorate.py @@ -23,35 +23,140 @@ # see http://www.gnu.org/licenses/. # # ############################################################################## - from dal import autocomplete from django import forms +from django.utils.translation import gettext_lazy as _ -from admission.contrib.models import DoctorateAdmission -from base.models.person import Person +from admission.contrib.models import AdmissionType +from ddd.logic.admission.preparation.projet_doctoral.domain.model._detail_projet import ChoixLangueRedactionThese +from ddd.logic.admission.preparation.projet_doctoral.domain.model._experience_precedente_recherche import \ + ChoixDoctoratDejaRealise +from ddd.logic.admission.preparation.projet_doctoral.domain.model._financement import ChoixTypeFinancement +from ddd.logic.admission.preparation.projet_doctoral.domain.model._enums import ChoixBureauCDE +from osis_document.contrib import FileUploadField -class DoctorateAdmissionCreateOrUpdateForm(forms.ModelForm): - candidate = forms.ModelChoiceField( - queryset=Person.objects.all(), - widget=autocomplete.ModelSelect2(url="admissions:person-autocomplete"), +class DoctorateAdmissionProjectForm(forms.Form): + type_admission = forms.ChoiceField( + label=_("Admission type"), + choices=AdmissionType.choices(), + widget=forms.RadioSelect, + ) + justification = forms.CharField( + label=_("Brief justification"), + widget=forms.Textarea(attrs={ + 'rows': 2, + 'placeholder': _("Detail here the reasons which justify the recourse to a provisory admission.") + }), + required=False, + ) + sector = forms.CharField( + label=_("Sector"), + widget=autocomplete.ListSelect2(url="admission:autocomplete:sector"), + ) + doctorate = forms.CharField( + label=_("Doctorate"), + widget=autocomplete.ListSelect2(url="admission:autocomplete:doctorate", forward=['sector']), + ) + bureau_cde = forms.ChoiceField( + label=_("Bureau"), + choices=(('', ' - '),) + ChoixBureauCDE.choices(), + required=False, ) - def __init__(self, *args, **kwargs): - # Retrieve the author passed from the view by get_form_kwargs - self.author = kwargs.pop("author", None) - super().__init__(*args, **kwargs) + type_financement = forms.ChoiceField( + label=_("Financing type"), + choices=(('', ' - '),) + ChoixTypeFinancement.choices(), + required=False, + ) + type_contrat_travail = forms.ChoiceField( + label=_("Work contract type"), + choices=(('', ' - '),), # FIXME + required=False, + ) + type_contrat_travail_other = forms.CharField( + label=_("Specify work contract"), + required=False, + ) + eft = forms.IntegerField( + label=_("Full-time equivalent"), + min_value=0, + max_value=100, + required=False, + ) + bourse_recherche = forms.ChoiceField( + label=_("Scholarship grant"), + choices=(('', ' - '),), # FIXME + required=False, + ) + bourse_recherche_other = forms.CharField( + label=_("Specify scholarship grant"), + max_length=255, + required=False, + ) + duree_prevue = forms.IntegerField( + label=_("Estimated time"), + min_value=0, + required=False, + ) + temps_consacre = forms.IntegerField( + label=_("Allocated time"), + min_value=0, + required=False, + ) - def save(self, commit=True): - if not hasattr(self.instance, "author"): - # Only for creation - self.instance.author = self.author - return super().save() + titre_projet = forms.CharField( + label=_("Project title"), + required=False, + ) + resume_projet = forms.CharField( + label=_("Project resume"), + required=False, + widget=forms.Textarea, + ) + documents_projet = FileUploadField( + label=_("Project documents"), + required=False, + ) + graphe_gantt = FileUploadField( + label=_("Gantt graph"), + required=False, + ) + proposition_programme_doctoral = FileUploadField( + label=_("Doctoral project proposition"), + required=False, + ) + projet_formation_complementaire = FileUploadField( + label=_("Complementary training project"), + required=False, + ) + langue_redaction_these = forms.ChoiceField( + label=_("Thesis redacting language"), + choices=ChoixLangueRedactionThese.choices(), + initial=ChoixLangueRedactionThese.UNDECIDED.name, + required=False, + ) + doctorat_deja_realise = forms.ChoiceField( + label=_("PhD already done"), + choices=ChoixDoctoratDejaRealise.choices(), + initial=ChoixDoctoratDejaRealise.NO.name, + required=False, + ) + institution = forms.CharField( + label=_("Institution"), + required=False, + ) + date_soutenance = forms.DateField( + label=_("Defense date"), + required=False, + ) + raison_non_soutenue = forms.CharField( + label=_("No defense reason"), + widget=forms.Textarea(attrs={ + 'rows': 2, + }), + required=False, + ) - class Meta: - model = DoctorateAdmission - fields = [ - "type", - "candidate", - "comment", - ] + class Media: + js = ('dependsOn.min.js',) diff --git a/contrib/models/__init__.py b/contrib/models/__init__.py index 06fef59bc..f30734489 100644 --- a/contrib/models/__init__.py +++ b/contrib/models/__init__.py @@ -28,11 +28,13 @@ from .doctorate import DoctorateAdmission from .comittee import CommitteeActor from .enums.admission_type import AdmissionType + from .entity_proxy import EntityProxy __all__ = [ "DoctorateAdmission", "AdmissionType", "CommitteeActor", + "EntityProxy", ] except RuntimeError as e: # pragma: no cover diff --git a/contrib/models/base.py b/contrib/models/base.py index 8b06b2212..944d94e8c 100644 --- a/contrib/models/base.py +++ b/contrib/models/base.py @@ -15,21 +15,20 @@ class BaseAdmission(models.Model): max_length=255, choices=AdmissionType.choices(), db_index=True, + default=AdmissionType.ADMISSION.name, ) candidate = models.ForeignKey( to="base.Person", verbose_name=_("Candidate"), related_name="admissions", - on_delete=models.CASCADE, - ) - comment = models.TextField(verbose_name=_("Comment")) - author = models.ForeignKey( - to='base.Person', - verbose_name=_('Author'), on_delete=models.PROTECT, - related_name='+', editable=False, ) + comment = models.TextField( + default='', + verbose_name=_("Comment"), + blank=True, + ) created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True) modified = models.DateTimeField(verbose_name=_('Modified'), auto_now=True) diff --git a/contrib/models/doctorate.py b/contrib/models/doctorate.py index be371aed4..c889fef22 100644 --- a/contrib/models/doctorate.py +++ b/contrib/models/doctorate.py @@ -25,24 +25,132 @@ # ############################################################################## from typing import Optional +from django.contrib.postgres.fields import JSONField from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ from base.models.person import Person +from ddd.logic.admission.preparation.projet_doctoral.domain.model._experience_precedente_recherche import \ + ChoixDoctoratDejaRealise +from ddd.logic.admission.preparation.projet_doctoral.domain.model._financement import ChoixTypeFinancement +from ddd.logic.admission.preparation.projet_doctoral.domain.model._enums import ChoixBureauCDE +from osis_document.contrib import FileField from osis_signature.contrib.fields import SignatureProcessField from .base import BaseAdmission from .enums.actor_type import ActorType class DoctorateAdmission(BaseAdmission): - doctoral_commission = models.ForeignKey( - to="base.Entity", - verbose_name=_("Doctoral commission"), + doctorate = models.ForeignKey( + to="base.EducationGroupYear", + verbose_name=_("Doctorate"), related_name="+", on_delete=models.CASCADE, + ) + bureau = models.CharField( + max_length=255, + verbose_name=_("Bureau"), + choices=ChoixBureauCDE.choices(), + default='', + blank=True, + ) + + # Financement + financing_type = models.CharField( + max_length=255, + verbose_name=_("Financing type"), + choices=ChoixTypeFinancement.choices(), + default='', + blank=True, + ) + financing_work_contract = models.CharField( + max_length=255, + verbose_name=_("Working contract type"), + default='', + blank=True, + ) + financing_eft = models.PositiveSmallIntegerField( + verbose_name=_("EFT"), + blank=True, null=True, ) + scholarship_grant = models.CharField( + max_length=255, + verbose_name=_("Scholarship grant"), + default='', + blank=True, + ) + planned_duration = models.PositiveSmallIntegerField( + verbose_name=_("Planned duration"), + blank=True, + null=True, + ) + dedicated_time = models.PositiveSmallIntegerField( + verbose_name=_("Dedicated time (in EFT)"), + blank=True, + null=True, + ) + + # Projet + project_title = models.CharField( + max_length=1023, + verbose_name=_("Project title"), + default='', + blank=True, + ) + project_abstract = models.TextField( + verbose_name=_("Abstract"), + default='', + blank=True, + ) + thesis_language = models.CharField( + max_length=255, + # TODO choices + verbose_name=_("Thesis language"), + default='', + blank=True, + ) + project_document = FileField( + verbose_name=_("Project"), + ) + gantt_graph = FileField( + verbose_name=_("Gantt graph"), + ) + program_proposition = FileField( + verbose_name=_("Program proposition"), + ) + additional_training_project = FileField( + verbose_name=_("Additional training project"), + ) + + # Experience précédente de recherche + phd_already_done = models.CharField( + max_length=255, + choices=ChoixDoctoratDejaRealise.choices(), + verbose_name=_("PhD already done"), + default='', + blank=True, + ) + phd_already_done_institution = models.CharField( + max_length=255, + verbose_name=_("Institution"), + default='', + blank=True, + ) + phd_already_done_defense_date = models.DateField( + verbose_name=_("Defense"), + null=True, + blank=True, + ) + phd_already_done_no_defense_reason = models.CharField( + max_length=255, + verbose_name=_("No defense reason"), + default='', + blank=True, + ) + + detailed_status = JSONField(default=dict) committee = SignatureProcessField() @@ -71,7 +179,7 @@ class Meta: ] def get_absolute_url(self): - return reverse("admissions:doctorate-detail", args=[self.pk]) + return reverse("admission:doctorate-detail", args=[self.pk]) @property def main_promoter(self) -> Optional[Person]: diff --git a/contrib/models/entity_proxy.py b/contrib/models/entity_proxy.py new file mode 100644 index 000000000..4a0c47603 --- /dev/null +++ b/contrib/models/entity_proxy.py @@ -0,0 +1,113 @@ +# ############################################################################## +# +# 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-2021 Université catholique de Louvain (http://www.uclouvain.be) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# A copy of this license - GNU General Public License - is available +# at the root of the source code of this program. If not, +# see http://www.gnu.org/licenses/. +# +# ############################################################################## + +from datetime import date + +from django.db import models + +from base.models.entity import Entity +from base.models.entity_version import EntityVersion +from base.utils.cte import CTESubquery + +__all__ = ['EntityProxy'] + + +class EntityQuerySet(models.QuerySet): + _last_version_qs = None + + def get_last_version(self): + """Get the last version subquery for entity version""" + if self._last_version_qs is None: + self._last_version_qs = EntityVersion.objects.filter( + entity=models.OuterRef('pk') + ).order_by('-start_date') + return self._last_version_qs + + def with_title(self): + return self.annotate( + title=models.Subquery( + self.get_last_version().values('title')[:1]), + ) + + def with_type(self): + return self.annotate( + type=models.Subquery( + self.get_last_version().values('entity_type')[:1]), + ) + + def with_parent(self): + return self.annotate( + parent=models.Subquery(self.get_last_version().values('parent')[:1]), + ) + + def with_acronym(self): + return self.annotate( + acronym=models.Subquery( + self.get_last_version().values('acronym')[:1]), + ) + + def with_acronym_path(self): + return self.annotate( + acronym_path=CTESubquery( + EntityVersion.objects.with_acronym_path( + entity_id=models.OuterRef('pk'), + ).values('acronym_path')[:1] + ), + ) + + def with_path_as_string(self): + return self.annotate( + path_as_string=CTESubquery( + EntityVersion.objects.with_acronym_path( + entity_id=models.OuterRef('pk'), + ).values('path_as_string')[:1], + output_field=models.TextField() + ), + ) + + def only_roots(self): + return self.annotate( + is_root=models.Exists(EntityVersion.objects.filter( + entity_id=models.OuterRef('pk'), + ).current(date.today()).only_roots()), + ).filter(is_root=True) + + def only_valid(self): + return self.annotate( + is_valid=models.Exists(self.get_last_version().exclude(end_date__lte=date.today())), + ).filter(is_valid=True) + + +class EntityManager(models.Manager.from_queryset(EntityQuerySet)): + pass + + +class EntityProxy(Entity): + """Proxy model of base.Entity""" + objects = EntityManager() + + class Meta: + proxy = True diff --git a/contrib/serializers/__init__.py b/contrib/serializers/__init__.py index 43eef886e..e2926fcd0 100644 --- a/contrib/serializers/__init__.py +++ b/contrib/serializers/__init__.py @@ -24,11 +24,5 @@ # # ############################################################################## -from .doctorate import ( - DoctorateAdmissionReadSerializer, DoctorateAdmissionWriteSerializer -) - -__all__ = [ - "DoctorateAdmissionReadSerializer", - "DoctorateAdmissionWriteSerializer", -] +from .doctorate import * +# from .person import * diff --git a/contrib/serializers/doctorate.py b/contrib/serializers/doctorate.py index 9705e2f0d..af0f4088d 100644 --- a/contrib/serializers/doctorate.py +++ b/contrib/serializers/doctorate.py @@ -24,18 +24,36 @@ # # ############################################################################## -from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from admission.contrib.models import DoctorateAdmission, AdmissionType -from base.models.person import Person +from admission.contrib.models import AdmissionType, DoctorateAdmission +from base.utils.serializers import DTOSerializer +from ddd.logic.admission.preparation.projet_doctoral.commands import ( + CompleterPropositionCommand, + InitierPropositionCommand, +) +from ddd.logic.admission.preparation.projet_doctoral.domain.model._detail_projet import ChoixLangueRedactionThese +from ddd.logic.admission.preparation.projet_doctoral.domain.model._experience_precedente_recherche import \ + ChoixDoctoratDejaRealise +from ddd.logic.admission.preparation.projet_doctoral.domain.model._enums import ChoixBureauCDE +from ddd.logic.admission.preparation.projet_doctoral.dtos import DoctoratDTO, PropositionDTO, PropositionSearchDTO + +__all__ = [ + "PropositionIdentityDTOSerializer", + "PropositionSearchDTOSerializer", + "InitierPropositionCommandSerializer", + "CompleterPropositionCommandSerializer", + "DoctorateAdmissionReadSerializer", + "DoctoratDTOSerializer", + "SectorDTOSerializer", + "PropositionDTOSerializer", +] class DoctorateAdmissionReadSerializer(serializers.ModelSerializer): url = serializers.ReadOnlyField(source="get_absolute_url") type = serializers.ReadOnlyField(source="get_type_display") candidate = serializers.StringRelatedField() - author = serializers.StringRelatedField() class Meta: model = DoctorateAdmission @@ -45,26 +63,79 @@ class Meta: "type", "candidate", "comment", - "author", "created", "modified", ] -class DoctorateAdmissionWriteSerializer(serializers.ModelSerializer): - type = serializers.ChoiceField(choices=AdmissionType.choices()) - candidate = serializers.PrimaryKeyRelatedField( - label=_("Candidate"), queryset=Person.objects.all() +class PropositionIdentityDTOSerializer(serializers.Serializer): + uuid = serializers.ReadOnlyField() + + +class PropositionSearchDTOSerializer(DTOSerializer): + class Meta: + source = PropositionSearchDTO + + +class PropositionDTOSerializer(DTOSerializer): + class Meta: + source = PropositionDTO + + +class InitierPropositionCommandSerializer(DTOSerializer): + class Meta: + source = InitierPropositionCommand + + type_admission = serializers.ChoiceField(choices=AdmissionType.choices()) + bureau_CDE = serializers.ChoiceField( + choices=ChoixBureauCDE.choices(), + allow_null=True, + default=None, + ) + documents_projet = serializers.ListField(child=serializers.UUIDField()) + graphe_gantt = serializers.ListField(child=serializers.UUIDField()) + proposition_programme_doctoral = serializers.ListField(child=serializers.UUIDField()) + projet_formation_complementaire = serializers.ListField(child=serializers.UUIDField()) + doctorat_deja_realise = serializers.ChoiceField( + choices=ChoixDoctoratDejaRealise.choices(), + default=ChoixDoctoratDejaRealise.NO.name, + ) + langue_redaction_these = serializers.ChoiceField( + choices=ChoixLangueRedactionThese.choices(), + default=ChoixLangueRedactionThese.UNDECIDED.name, ) - def create(self, validated_data): - validated_data['author'] = self.context["request"].user.person - return super().create(validated_data) +class CompleterPropositionCommandSerializer(DTOSerializer): class Meta: - model = DoctorateAdmission - fields = [ - "type", - "candidate", - "comment", - ] + source = CompleterPropositionCommand + + type_admission = serializers.ChoiceField(choices=AdmissionType.choices()) + bureau_CDE = serializers.ChoiceField( + choices=ChoixBureauCDE.choices(), + allow_null=True, + default=None, + ) + documents_projet = serializers.ListField(child=serializers.UUIDField()) + graphe_gantt = serializers.ListField(child=serializers.UUIDField()) + proposition_programme_doctoral = serializers.ListField(child=serializers.UUIDField()) + projet_formation_complementaire = serializers.ListField(child=serializers.UUIDField()) + doctorat_deja_realise = serializers.ChoiceField( + choices=ChoixDoctoratDejaRealise.choices(), + default=ChoixDoctoratDejaRealise.NO.name, + ) + langue_redaction_these = serializers.ChoiceField( + choices=ChoixLangueRedactionThese.choices(), + default=ChoixLangueRedactionThese.UNDECIDED.name, + ) + + +class SectorDTOSerializer(serializers.Serializer): + sigle = serializers.ReadOnlyField() + intitule_fr = serializers.ReadOnlyField() + intitule_en = serializers.ReadOnlyField() + + +class DoctoratDTOSerializer(DTOSerializer): + class Meta: + source = DoctoratDTO diff --git a/contrib/serializers/person.py b/contrib/serializers/person.py new file mode 100644 index 000000000..7a14db17c --- /dev/null +++ b/contrib/serializers/person.py @@ -0,0 +1,92 @@ +# ############################################################################## +# +# 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-2021 Université catholique de Louvain (http://www.uclouvain.be) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# A copy of this license - GNU General Public License - is available +# at the root of the source code of this program. If not, +# see http://www.gnu.org/licenses/. +# +# ############################################################################## + +from rest_framework import serializers + +from base.models.person import Person +from osis_profile.models import Profile + +__all__ = [ + "DoctorateAdmissionProfileSerializer", + "DoctorateAdmissionPersonalInfoSerializer", + "DoctorateAdmissionPersonTabSerializer", +] + + +class LaxSerializerMixin: + serializer_field_mapping = serializers.ModelSerializer.serializer_field_mapping + + # serializer_field_mapping[FileField] = DocumentField + + def __init__(self, all_fields_optional=True, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.all_fields_optional = all_fields_optional + + def include_extra_kwargs(self, kwargs, extra_kwargs): + if self.all_fields_optional: + extra_kwargs['required'] = False + return super().include_extra_kwargs(kwargs, extra_kwargs) + + def build_standard_field(self, field_name, model_field): + if self.all_fields_optional: + model_field.blank = True + return super().build_standard_field(field_name, model_field) + + +class DoctorateAdmissionPersonalInfoSerializer(LaxSerializerMixin, serializers.ModelSerializer): + class Meta: + model = Person + fields = [ + 'first_name', + 'last_name', + 'language', + 'birth_date', + ] + + +class DoctorateAdmissionProfileSerializer(LaxSerializerMixin, serializers.ModelSerializer): + class Meta: + model = Profile + fields = [ + 'id_photo', + 'birth_year', + 'birth_place', + 'national_number', + 'id_card_number', + 'passport_number', + 'passport_expiration_date', + 'id_card', + 'passport', + 'iban', + 'bic_swift', + 'bank_holder_name', + 'last_registration_year', + ] + + +class DoctorateAdmissionPersonTabSerializer(serializers.Serializer): + person = DoctorateAdmissionPersonalInfoSerializer(all_fields_optional=False) + profile = DoctorateAdmissionProfileSerializer(all_fields_optional=False) diff --git a/contrib/views/doctorate.py b/contrib/views/doctorate.py index b89b0edfc..8dcfdd286 100644 --- a/contrib/views/doctorate.py +++ b/contrib/views/doctorate.py @@ -25,48 +25,21 @@ # ############################################################################## from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse, reverse_lazy +from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ -from django.views.generic import CreateView, DeleteView, DetailView, UpdateView -from django_filters.views import FilterView +from django.views.generic import DeleteView, TemplateView -from admission.contrib.filters import DoctorateAdmissionFilter -from admission.contrib.forms import DoctorateAdmissionCreateOrUpdateForm from admission.contrib.models import DoctorateAdmission -from admission.contrib.serializers import DoctorateAdmissionReadSerializer -from base.utils.search import SearchMixin -from osis_role.contrib.views import PermissionRequiredMixin +__all__ = [ + "DoctorateAdmissionCancelView", + "DoctorateAdmissionListView", +] -class DoctorateAdmissionCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - CreateView, -): - model = DoctorateAdmission - template_name = "admission/doctorate/admission_doctorate_create.html" - form_class = DoctorateAdmissionCreateOrUpdateForm - success_message = _("Record successfully saved") - permission_required = 'admission.add_doctorateadmission' - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - user = self.request.user - kwargs.update({"author": user.person}) - return kwargs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["cancel_url"] = reverse("admissions:doctorate-list") - return context - -class DoctorateAdmissionDeleteView(DeleteView): +class DoctorateAdmissionCancelView(DeleteView): model = DoctorateAdmission - success_url = reverse_lazy("admissions:doctorate-list") + success_url = reverse_lazy("admission:doctorate-list") success_message = _("Doctorate admission was successfully deleted") def delete(self, request, *args, **kwargs): @@ -75,32 +48,5 @@ def delete(self, request, *args, **kwargs): return super().delete(request, *args, **kwargs) -class DoctorateAdmissionDetailView(DetailView): - model = DoctorateAdmission - template_name = "admission/doctorate/admission_doctorate_detail.html" - - -class DoctorateAdmissionListView(SearchMixin, FilterView): - model = DoctorateAdmission +class DoctorateAdmissionListView(TemplateView): template_name = "admission/doctorate/admission_doctorate_list.html" - context_object_name = "doctorates" - filterset_class = DoctorateAdmissionFilter - serializer_class = DoctorateAdmissionReadSerializer - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - if context["paginator"].count == 0 and self.request.GET: - messages.add_message(self.request, messages.WARNING, _("No result!")) - # FIXME When getting no results, the warning message above is well displayed - # But doing a research that is returning results just after still shows the message - context.update({ - "items_per_page": context["paginator"].per_page, - }) - return context - - -class DoctorateAdmissionUpdateView(SuccessMessageMixin, UpdateView): - model = DoctorateAdmission - template_name = "admission/doctorate/admission_doctorate_update.html" - form_class = DoctorateAdmissionCreateOrUpdateForm - success_message = _("Doctorate admission was successfully updated") diff --git a/migrations/0001_initial.py b/migrations/0001_initial.py index 132f5d217..372b800e3 100644 --- a/migrations/0001_initial.py +++ b/migrations/0001_initial.py @@ -1,7 +1,9 @@ -# Generated by Django 2.2.13 on 2021-07-28 10:28 +# Generated by Django 2.2.13 on 2021-09-16 15:20 +import django.contrib.postgres.fields.jsonb from django.db import migrations, models import django.db.models.deletion +import osis_document.contrib.fields import osis_signature.contrib.fields import uuid @@ -11,8 +13,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('base', '0592_auto_20210812_1142'), ('osis_signature', '0001_initial'), - ('base', '0591_remove_organization_code'), ] operations = [ @@ -24,6 +26,17 @@ class Migration(migrations.Migration): ], bases=('osis_signature.actor',), ), + migrations.CreateModel( + name='EntityProxy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('base.entity',), + ), migrations.CreateModel( name='SicManager', fields=[ @@ -94,19 +107,37 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), - ('type', models.CharField(choices=[('ADMISSION', 'Admission'), ('PRE_ADMISSION', 'Pre-Admission')], db_index=True, max_length=255, verbose_name='Type')), - ('comment', models.TextField(verbose_name='Comment')), + ('type', models.CharField(choices=[('ADMISSION', 'Admission'), ('PRE_ADMISSION', 'Pre-Admission')], db_index=True, default='ADMISSION', max_length=255, verbose_name='Type')), + ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')), ('modified', models.DateTimeField(auto_now=True, verbose_name='Modified')), - ('author', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='base.Person', verbose_name='Author')), - ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admissions', to='base.Person', verbose_name='Candidate')), + ('bureau', models.CharField(blank=True, choices=[('ECONOMY', 'ECONOMY'), ('MANAGEMENT', 'MANAGEMENT')], default='', max_length=255, verbose_name='Bureau')), + ('financing_type', models.CharField(blank=True, choices=[('WORK_CONTRACT', 'WORK_CONTRACT'), ('SEARCH_SCHOLARSHIP', 'SEARCH_SCHOLARSHIP'), ('SELF_FUNDING', 'SELF_FUNDING')], default='', max_length=255, verbose_name='Financing type')), + ('financing_work_contract', models.CharField(blank=True, default='', max_length=255, verbose_name='Working contract type')), + ('financing_eft', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='EFT')), + ('scholarship_grant', models.CharField(blank=True, default='', max_length=255, verbose_name='Scholarship grant')), + ('planned_duration', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Planned duration')), + ('dedicated_time', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Dedicated time (in EFT)')), + ('project_title', models.CharField(blank=True, default='', max_length=1023, verbose_name='Project title')), + ('project_abstract', models.TextField(blank=True, default='', verbose_name='Abstract')), + ('thesis_language', models.CharField(blank=True, default='', max_length=255, verbose_name='Thesis language')), + ('project_document', osis_document.contrib.fields.FileField(base_field=models.UUIDField(), default=list, size=None, verbose_name='Project')), + ('gantt_graph', osis_document.contrib.fields.FileField(base_field=models.UUIDField(), default=list, size=None, verbose_name='Gantt graph')), + ('program_proposition', osis_document.contrib.fields.FileField(base_field=models.UUIDField(), default=list, size=None, verbose_name='Program proposition')), + ('additional_training_project', osis_document.contrib.fields.FileField(base_field=models.UUIDField(), default=list, size=None, verbose_name='Additional training project')), + ('phd_already_done', models.CharField(blank=True, choices=[('YES', 'YES'), ('NO', 'NO'), ('PARTIAL', 'PARTIAL')], default='', max_length=255, verbose_name='PhD already done')), + ('phd_already_done_institution', models.CharField(blank=True, default='', max_length=255, verbose_name='Institution')), + ('phd_already_done_defense_date', models.DateField(blank=True, null=True, verbose_name='Defense')), + ('phd_already_done_no_defense_reason', models.CharField(blank=True, default='', max_length=255, verbose_name='No defense reason')), + ('detailed_status', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), + ('candidate', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='admissions', to='base.Person', verbose_name='Candidate')), ('committee', osis_signature.contrib.fields.SignatureProcessField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='osis_signature.Process')), - ('doctoral_commission', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='base.Entity', verbose_name='Doctoral commission')), + ('doctorate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='base.EducationGroupYear', verbose_name='Doctorate')), ], options={ 'verbose_name': 'Doctorate admission', 'ordering': ('-created',), - 'permissions': [('access_doctorateadmission', 'Can access doctorate admissions'), ('download_jury_approved_pdf', 'Can download jury-approved PDF'), ('upload_jury_approved_pdf', 'Can upload jury-approved PDF'), ('upload_signed_scholarship', 'Can upload signed scholarship'), ('check_publication_authorisation', 'Can check publication autorisation'), ('validate_registration', 'Can validate registration'), ('approve_jury', 'Can approve jury'), ('approve_confirmation_paper', 'Can approve confirmation paper'), ('validate_doctoral_training', 'Can validate doctoral training'), ('download_pdf_confirmation', 'Can download PDF confirmation'), ('upload_pdf_confirmation', 'Can upload PDF confirmation'), ('fill_thesis', 'Can fill thesis'), ('submit_thesis', 'Can submit thesis'), ('appose_cdd_notice', 'Can appose CDD notice'), ('appose_sic_notice', 'Can appose SIC notice'), ('upload_defense_report', 'Can upload defense report'), ('check_copyright', 'Can check copyright'), ('sign_diploma', 'Can sign diploma')], + 'permissions': [('access_doctorateadmission', 'Can access doctorate admission list'), ('download_jury_approved_pdf', 'Can download jury-approved PDF'), ('upload_jury_approved_pdf', 'Can upload jury-approved PDF'), ('upload_signed_scholarship', 'Can upload signed scholarship'), ('check_publication_authorisation', 'Can check publication autorisation'), ('validate_registration', 'Can validate registration'), ('approve_jury', 'Can approve jury'), ('approve_confirmation_paper', 'Can approve confirmation paper'), ('validate_doctoral_training', 'Can validate doctoral training'), ('download_pdf_confirmation', 'Can download PDF confirmation'), ('upload_pdf_confirmation', 'Can upload PDF confirmation'), ('fill_thesis', 'Can fill thesis'), ('submit_thesis', 'Can submit thesis'), ('appose_cdd_notice', 'Can appose CDD notice'), ('appose_sic_notice', 'Can appose SIC notice'), ('upload_defense_report', 'Can upload defense report'), ('check_copyright', 'Can check copyright'), ('sign_diploma', 'Can sign diploma')], }, ), migrations.CreateModel( @@ -119,7 +150,7 @@ class Migration(migrations.Migration): ], options={ 'verbose_name': 'Committee member', - 'verbose_name_plural': 'Committee member', + 'verbose_name_plural': 'Committee members', }, ), migrations.CreateModel( diff --git a/schema.yml b/schema.yml new file mode 100644 index 000000000..5596fd520 --- /dev/null +++ b/schema.yml @@ -0,0 +1,612 @@ +openapi: 3.0.0 +info: + title: Admission API + version: '' + description: This API delivers data for the Admission project. + contact: + name: UCLouvain - OSIS + url: https://github.com/uclouvain/osis +paths: + /propositions: + get: + operationId: list_propositions + 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/PropositionSearchDTO' + description: '' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + tags: + - propositions + post: + operationId: create_proposition + 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' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InitierPropositionCommand' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/PropositionIdentityDTO' + description: '' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + tags: + - propositions + /propositions/{uuid}: + get: + operationId: retrieve_proposition + description: Get a single proposition + parameters: + - name: uuid + in: path + required: true + description: '' + schema: + type: string + - $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: + $ref: '#/components/schemas/PropositionDTO' + description: '' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + tags: + - propositions + put: + operationId: update_proposition + description: Edit a proposition + parameters: + - name: uuid + in: path + required: true + description: '' + schema: + type: string + - $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' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleterPropositionCommand' + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PropositionIdentityDTO' + description: '' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + tags: + - propositions + tags: + - propositions + /autocomplete/sector: + get: + operationId: listSectorDTOs + description: Autocomplete sectors + 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/SectorDTO' + description: '' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + tags: + - autocomplete + /autocomplete/sector/{sigle}/doctorates: + get: + operationId: listDoctoratDTOs + description: Autocomplete doctorates given a sector + parameters: + - name: sigle + in: path + required: true + description: '' + schema: + type: string + - $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/DoctoratDTO' + description: '' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + tags: + - autocomplete +components: + schemas: + PropositionSearchDTO: + type: object + properties: + uuid: + type: string + type_admission: + type: string + sigle_doctorat: + type: string + intitule_doctorat_fr: + type: string + intitule_doctorat_en: + type: string + matricule_candidat: + type: string + code_secteur_formation: + type: string + bureau_CDE: + type: string + created_at: + type: string + format: date-time + required: + - uuid + - type_admission + - sigle_doctorat + - intitule_doctorat_fr + - intitule_doctorat_en + - matricule_candidat + - code_secteur_formation + - created_at + PropositionDTO: + type: object + properties: + type_admission: + type: string + justification: + type: string + sigle_doctorat: + type: string + annee_doctorat: + type: integer + intitule_doctorat_fr: + type: string + intitule_doctorat_en: + type: string + matricule_candidat: + type: string + code_secteur_formation: + type: string + bureau_CDE: + type: string + type_financement: + type: string + type_contrat_travail: + type: string + eft: + type: integer + nullable: true + bourse_recherche: + type: string + duree_prevue: + type: integer + nullable: true + temps_consacre: + type: integer + nullable: true + titre_projet: + type: string + resume_projet: + type: string + documents_projet: + type: array + items: + type: string + graphe_gantt: + type: array + items: + type: string + proposition_programme_doctoral: + type: array + items: + type: string + projet_formation_complementaire: + type: array + items: + type: string + langue_redaction_these: + type: string + doctorat_deja_realise: + type: string + institution: + type: string + date_soutenance: + type: string + format: date + nullable: true + raison_non_soutenue: + type: string + required: + - type_admission + - sigle_doctorat + - annee_doctorat + - intitule_doctorat_fr + - intitule_doctorat_en + - matricule_candidat + - code_secteur_formation + - documents_projet + - graphe_gantt + - proposition_programme_doctoral + - projet_formation_complementaire + - langue_redaction_these + - doctorat_deja_realise + SectorDTO: + type: object + properties: + sigle: + type: string + readOnly: true + intitule_fr: + type: string + readOnly: true + intitule_en: + type: string + readOnly: true + DoctoratDTO: + type: object + properties: + sigle: + type: string + annee: + type: integer + intitule_fr: + type: string + intitule_en: + type: string + sigle_entite_gestion: + type: string + required: + - sigle + - annee + - intitule_fr + - intitule_en + - sigle_entite_gestion + PropositionIdentityDTO: + type: object + properties: + uuid: + type: string + readOnly: true + InitierPropositionCommand: + type: object + properties: + type_admission: + $ref: '#/components/responses/AdmissionType' + sigle_formation: + type: string + annee_formation: + type: integer + matricule_candidat: + type: string + justification: + type: string + bureau_CDE: + $ref: '#/components/responses/ChoixBureauCDE' + nullable: true + type_financement: + type: string + type_contrat_travail: + type: string + eft: + type: integer + nullable: true + bourse_recherche: + type: string + duree_prevue: + type: integer + nullable: true + temps_consacre: + type: integer + nullable: true + titre_projet: + type: string + resume_projet: + type: string + documents_projet: + type: array + items: + type: string + graphe_gantt: + type: array + items: + type: string + proposition_programme_doctoral: + type: array + items: + type: string + projet_formation_complementaire: + type: array + items: + type: string + langue_redaction_these: + $ref: '#/components/responses/ChoixLangueRedactionThese' + default: UNDECIDED + doctorat_deja_realise: + $ref: '#/components/responses/ChoixDoctoratDejaRealise' + default: 'NO' + institution: + type: string + date_soutenance: + type: string + format: date + nullable: true + raison_non_soutenue: + type: string + required: + - type_admission + - sigle_formation + - annee_formation + - matricule_candidat + - documents_projet + - graphe_gantt + - proposition_programme_doctoral + - projet_formation_complementaire + AdmissionType: + enum: + - ADMISSION + - PRE_ADMISSION + type: string + ChoixBureauCDE: + enum: + - ECONOMY + - MANAGEMENT + type: string + ChoixLangueRedactionThese: + enum: + - FRENCH + - ENGLISH + - UNDECIDED + type: string + ChoixDoctoratDejaRealise: + enum: + - 'YES' + - 'NO' + - PARTIAL + type: string + CompleterPropositionCommand: + type: object + properties: + uuid: + type: string + type_admission: + $ref: '#/components/responses/AdmissionType' + justification: + type: string + bureau_CDE: + $ref: '#/components/responses/ChoixBureauCDE' + nullable: true + type_financement: + type: string + type_contrat_travail: + type: string + eft: + type: integer + nullable: true + bourse_recherche: + type: string + duree_prevue: + type: integer + nullable: true + temps_consacre: + type: integer + nullable: true + titre_projet: + type: string + resume_projet: + type: string + documents_projet: + type: array + items: + type: string + graphe_gantt: + type: array + items: + type: string + proposition_programme_doctoral: + type: array + items: + type: string + projet_formation_complementaire: + type: array + items: + type: string + langue_redaction_these: + $ref: '#/components/responses/ChoixLangueRedactionThese' + default: UNDECIDED + doctorat_deja_realise: + $ref: '#/components/responses/ChoixDoctoratDejaRealise' + default: 'NO' + institution: + type: string + date_soutenance: + type: string + format: date + nullable: true + raison_non_soutenue: + type: string + required: + - uuid + - type_admission + - documents_projet + - graphe_gantt + - proposition_programme_doctoral + - projet_formation_complementaire + DefinirCotutelleCommand: + type: object + properties: + uuid_proposition: + type: string + motivation: + type: string + institution: + type: string + document_demande_ouverture: + type: array + items: + type: string + required: + - uuid_proposition + - document_demande_ouverture + Error: + type: object + properties: + code: + type: string + message: + type: string + required: + - code + - message + AcceptedLanguageEnum: + type: string + enum: + - en + - fr-be + securitySchemes: + Token: + type: apiKey + in: header + name: Authorization + description: Enter your token in the format **Token <token>** + parameters: + X-User-FirstName: + in: header + name: X-User-FirstName + schema: + type: string + required: false + X-User-LastName: + in: header + name: X-User-LastName + schema: + type: string + required: false + X-User-Email: + in: header + name: X-User-Email + schema: + type: string + required: false + X-User-GlobalID: + in: header + name: X-User-GlobalID + schema: + type: string + required: false + Accept-Language: + in: header + name: Accept-Language + description: The header advertises which languages the client is able to understand, + and which locale variant is preferred. (By languages, we mean natural languages, + such as English, and not programming languages.) + schema: + $ref: '#/components/schemas/AcceptedLanguageEnum' + required: false + responses: + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + BadRequest: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +servers: +- url: https://{environment}.osis.uclouvain.be/api/v1/admission/ + variables: + environment: + default: dev + enum: + - dev + - qa + - test +- url: https://osis.uclouvain.be/api/v1/admission/ + description: Production server +security: +- Token: [] diff --git a/tests/api/views/test_doctorate.py b/tests/api/views/test_doctorate.py index d360738a6..168bc3022 100644 --- a/tests/api/views/test_doctorate.py +++ b/tests/api/views/test_doctorate.py @@ -38,23 +38,19 @@ class DoctorateAdmissionApiTestCase(TestCase): @classmethod def setUpTestData(cls): - cls.author = PersonFactory() cls.candidate = PersonFactory() cls.create_data = { "type": AdmissionType.PRE_ADMISSION.name, - "candidate": cls.candidate.pk, "comment": "test admission doctorate serializer", } cls.create_url = reverse("admission_api_v1:doctorate-list") cls.admission = DoctorateAdmissionFactory( type=AdmissionType.PRE_ADMISSION.name, - candidate=cls.author, - author=cls.author, + candidate=cls.candidate, comment="test admission doctorate serializer", ) cls.update_data = { "type": AdmissionType.ADMISSION.name, - "candidate": cls.candidate.pk, "comment": "updated comment", } cls.update_url = reverse( @@ -63,19 +59,17 @@ def setUpTestData(cls): ) def test_admission_doctorate_creation_using_api(self): - self.client.force_login(self.author.user) + self.client.force_login(self.candidate.user) response = self.client.post(self.create_url, data=self.create_data) self.assertEqual(response.status_code, 201) admissions = DoctorateAdmission.objects.all() self.assertEqual(admissions.count(), 2) admission = admissions.get(uuid=response.data["uuid"]) - self.assertEqual(admission.author, self.author) self.assertEqual(admission.type, self.create_data["type"]) - self.assertEqual(admission.candidate.pk, self.create_data["candidate"]) self.assertEqual(admission.comment, self.create_data["comment"]) def test_admission_doctorate_update_using_api(self): - self.client.force_login(self.author.user) + self.client.force_login(self.candidate.user) response = self.client.patch( self.update_url, data=self.update_data, @@ -85,8 +79,7 @@ def test_admission_doctorate_update_using_api(self): admissions = DoctorateAdmission.objects.all() self.assertEqual(admissions.count(), 1) # The author must not change - self.assertEqual(admissions.get().author, self.author) + self.assertEqual(admissions.get().candidate, self.candidate) # But all the following should self.assertEqual(admissions.get().type, self.update_data["type"]) - self.assertEqual(admissions.get().candidate.pk, self.update_data["candidate"]) self.assertEqual(admissions.get().comment, self.update_data["comment"]) diff --git a/tests/factories/doctorate.py b/tests/factories/doctorate.py index 50800bce0..0980634d3 100644 --- a/tests/factories/doctorate.py +++ b/tests/factories/doctorate.py @@ -34,5 +34,4 @@ class DoctorateAdmissionFactory(factory.DjangoModelFactory): class Meta: model = DoctorateAdmission - author = factory.SubFactory(PersonFactory) - candidate = factory.SelfAttribute('author') + candidate = factory.SubFactory(PersonFactory) diff --git a/tests/test_predicates.py b/tests/test_predicates.py index 6d7177872..5bb8dca9b 100644 --- a/tests/test_predicates.py +++ b/tests/test_predicates.py @@ -38,11 +38,11 @@ class PredicatesTestCase(TestCase): def test_is_admission_request_author(self): - author1 = CandidateFactory().person - author2 = CandidateFactory().person - request = DoctorateAdmissionFactory(author=author1) - self.assertTrue(predicates.is_admission_request_author(author1.user, request)) - self.assertFalse(predicates.is_admission_request_author(author2.user, request)) + candidate1 = CandidateFactory().person + candidate2 = CandidateFactory().person + request = DoctorateAdmissionFactory(candidate=candidate1) + self.assertTrue(predicates.is_admission_request_author(candidate1.user, request)) + self.assertFalse(predicates.is_admission_request_author(candidate2.user, request)) def test_is_main_promoter(self): author = CandidateFactory().person diff --git a/tests/views/test_doctorate.py b/tests/views/test_doctorate.py index 428779b96..7871c38e0 100644 --- a/tests/views/test_doctorate.py +++ b/tests/views/test_doctorate.py @@ -26,55 +26,49 @@ from django.test import tag from django.urls import reverse -from django.utils.translation import gettext_lazy as _ from admission.contrib.models import DoctorateAdmission, AdmissionType -from admission.contrib.views import DoctorateAdmissionDeleteView from admission.tests import TestCase from admission.tests.factories import DoctorateAdmissionFactory -from admission.tests.factories.roles import CandidateFactory +from base.models.enums.entity_type import SECTOR +from base.tests.factories.entity_version import EntityVersionFactory from base.tests.factories.person import PersonFactory class DoctorateAdmissionCreateViewTest(TestCase): @classmethod def setUpTestData(cls): - cls.person = CandidateFactory().person cls.candidate = PersonFactory() - cls.url = reverse("admissions:doctorate-create") + cls.url = reverse("admission:doctorate-create:project") cls.data = { "comment": "this is a test", "type": AdmissionType.ADMISSION.name, - "candidate": cls.candidate.id, } + EntityVersionFactory(acronym='CDE') - def test_create_doctorate_admission_add_user_as_author(self): - self.client.force_login(self.person.user) + def test_create_doctorate_admission_user_is_candidate(self): + self.client.force_login(self.candidate.user) response = self.client.post(self.url, data=self.data, follow=True) self.assertEqual(response.status_code, 200) - # check that the object in the response got the person as author - self.assertEqual(response.context_data["object"].author, self.person) - # and double check by getting it from the db - admission_author = DoctorateAdmission.objects.get( - candidate=self.candidate.id - ).author - self.assertEqual(admission_author, self.person) + # Check that the created object got the person as author + admission_author = DoctorateAdmission.objects.get().candidate + self.assertEqual(admission_author, self.candidate) def test_create_doctorate_admission_redirect_to_detail_view(self): - self.client.force_login(self.person.user) + self.client.force_login(self.candidate.user) response = self.client.post(self.url, data=self.data, follow=True) self.assertEqual(response.status_code, 200) # make sure that the DoctorateAdmission creation redirect to the detail view self.assertTemplateUsed( response, - "admission/doctorate/admission_doctorate_detail.html", + "admission/doctorate/detail_person.html", ) - def test_view_context_data_contains_cancel_url(self): - self.client.force_login(self.person.user) + def test_view_context_data_contains_CDE_id(self): + self.client.force_login(self.candidate.user) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertIsNotNone(response.context["cancel_url"]) + self.assertIsNotNone(response.context["CDE_id"]) class DoctorateAdmissionListViewTest(TestCase): @@ -83,23 +77,20 @@ def setUpTestData(cls): cls.person = PersonFactory() DoctorateAdmissionFactory( candidate=cls.person, - author=cls.person, type=AdmissionType.ADMISSION.name, comment="First admission", ) DoctorateAdmissionFactory( candidate=cls.person, - author=cls.person, type=AdmissionType.ADMISSION.name, comment="Second admission", ) DoctorateAdmissionFactory( candidate=cls.person, - author=cls.person, type=AdmissionType.PRE_ADMISSION.name, comment="A pre-admission", ) - cls.url = reverse("admissions:doctorate-list") + cls.url = reverse("admission:doctorate-list") def test_view_context_data_contains_items_per_page(self): self.client.force_login(self.person.user) @@ -107,16 +98,6 @@ def test_view_context_data_contains_items_per_page(self): self.assertEqual(response.status_code, 200) self.assertIsNotNone(response.context["items_per_page"]) - def test_message_is_triggered_if_no_results(self): - self.client.force_login(self.person.user) - response = self.client.get( - self.url, data={"type": "this type doesn't exist"} - ) - self.assertEqual(response.status_code, 200) - messages = list(response.context["messages"]) - self.assertEqual(len(messages), 1) - self.assertEqual(str(messages[0]), _("No result!")) - def test_filtering_by_admission_type(self): self.client.force_login(self.person.user) response = self.client.get( @@ -148,32 +129,6 @@ def test_get_num_queries_serializer(self): self.client.get(self.url, HTTP_ACCEPT='application/json') -class DoctorateAdmissionDeleteViewTest(TestCase): - @classmethod - def setUpTestData(cls): - cls.candidate = PersonFactory() - cls.admission = DoctorateAdmissionFactory( - candidate=cls.candidate, author=cls.candidate - ) - cls.url = reverse("admissions:doctorate-delete", args=[cls.admission.pk]) - - def test_delete_view_sends_message_when_object_is_deleted(self): - self.client.force_login(self.candidate.user) - response = self.client.post(self.url, follow=True) - self.assertEqual(response.status_code, 200) - messages = list(response.context["messages"]) - self.assertEqual(len(messages), 1) - self.assertEqual( - str(messages[0]), _(DoctorateAdmissionDeleteView.success_message) - ) - - def test_delete_view_removes_admission_from_db(self): - self.client.force_login(self.candidate.user) - response = self.client.post(self.url, follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(DoctorateAdmission.objects.count(), 0) - - class DoctorateAdmissionUpdateViewTest(TestCase): @classmethod def setUpTestData(cls): @@ -181,16 +136,17 @@ def setUpTestData(cls): cls.new_candidate = PersonFactory() cls.admission = DoctorateAdmissionFactory( candidate=cls.candidate, - author=cls.candidate, comment="A comment", type=AdmissionType.ADMISSION, ) + sector = EntityVersionFactory(entity_type=SECTOR).entity_id cls.update_data = { "type": AdmissionType.PRE_ADMISSION.name, "comment": "New comment", - "candidate": cls.new_candidate.pk, + "sector": sector, + "doctorate": EntityVersionFactory(parent_id=sector).entity_id, } - cls.url = reverse("admissions:doctorate-update", args=[cls.admission.pk]) + cls.url = reverse("admission:doctorate-update:project", args=[cls.admission.pk]) def test_doctorate_admission_is_updated(self): self.client.force_login(self.candidate.user) diff --git a/urls.py b/urls.py index 25215e36a..993fd39c7 100644 --- a/urls.py +++ b/urls.py @@ -24,53 +24,6 @@ # # ############################################################################## -from django.urls import path, include +app_name = "admission" -from .contrib.views import ( - DoctorateAdmissionCreateView, - DoctorateAdmissionDeleteView, - DoctorateAdmissionDetailView, - DoctorateAdmissionListView, - DoctorateAdmissionUpdateView, - autocomplete, -) - -app_name = "admissions" -urlpatterns = [ - path("doctorates/", DoctorateAdmissionListView.as_view(), name="doctorate-list"), - path( - "doctorates/create/", - DoctorateAdmissionCreateView.as_view(), - name="doctorate-create", - ), - path( - "doctorates//", - DoctorateAdmissionDetailView.as_view(), - name="doctorate-detail", - ), - path( - "doctorates//delete/", - DoctorateAdmissionDeleteView.as_view(), - name="doctorate-delete", - ), - path( - "doctorates//update/", - DoctorateAdmissionUpdateView.as_view(), - name="doctorate-update", - ), - - path("autocomplete/", include( - [ - path( - "person/", - autocomplete.PersonAutocomplete.as_view(), - name="person-autocomplete", - ), - path( - "candidate/", - autocomplete.CandidateAutocomplete.as_view(), - name="candidate-autocomplete", - ), - ] - )), -] +urlpatterns = ()