From 48376724f5978df42b1ecf4869622f30507c204f Mon Sep 17 00:00:00 2001 From: Iziram Date: Wed, 26 Jun 2024 10:32:15 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Assiduit=C3=A9=20:=20Permission=20JustifVal?= =?UTF-8?q?idate=20#823?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/justificatifs.py | 9 ++- app/forms/assiduite/ajout_assiduite_etud.py | 8 +-- app/scodoc/sco_formsemestre_status.py | 4 +- app/scodoc/sco_permissions.py | 5 ++ app/views/assiduites.py | 69 ++++++++++++++++++++- 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index ad77f54cd..28063d059 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -374,6 +374,10 @@ def _create_one( errors.append("param 'etat': invalide") etat: scu.EtatJustificatif = scu.EtatJustificatif.get(etat) + if etat != scu.EtatJustificatif.ATTENTE and not current_user.has_permission( + Permission.JustifValidate + ): + errors.append("param 'etat': non autorisé (Permission.JustifValidate)") # cas 2 : date_debut date_debut: str = data.get("date_debut", None) @@ -473,7 +477,10 @@ def justif_edit(justif_id: int): if etat is None: errors.append("param 'etat': invalide") else: - justificatif_unique.etat = etat + if current_user.has_permission(Permission.JustifValidate): + justificatif_unique.etat = etat + else: + errors.append("param 'etat': non autorisé (Permission.JustifValidate)") # Cas 2 : raison raison: str = data.get("raison", False) diff --git a/app/forms/assiduite/ajout_assiduite_etud.py b/app/forms/assiduite/ajout_assiduite_etud.py index 302f73776..5489ac350 100644 --- a/app/forms/assiduite/ajout_assiduite_etud.py +++ b/app/forms/assiduite/ajout_assiduite_etud.py @@ -170,13 +170,7 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm): ) etat = SelectField( "État du justificatif", - choices=[ - ("", "Choisir..."), # Placeholder - (scu.EtatJustificatif.ATTENTE.value, "En attente de validation"), - (scu.EtatJustificatif.NON_VALIDE.value, "Non valide"), - (scu.EtatJustificatif.MODIFIE.value, "Modifié"), - (scu.EtatJustificatif.VALIDE.value, "Valide"), - ], + choices=[], # sera rempli dynamiquement validators=[DataRequired(message="This field is required.")], ) fichiers = MultipleFileField(label="Ajouter des fichiers") diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 8c3ac414a..4053e694e 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1136,7 +1136,9 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True): # --- Lien Traitement Justificatifs: - if current_user.has_permission(Permission.AbsJustifView): + if current_user.has_permission( + Permission.AbsJustifView + ) and current_user.has_permission(Permission.JustifValidate): H.append( f"""

Date: Wed, 26 Jun 2024 11:20:28 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Remise=20en=20=C3=A9tat=20api/assiduites/ev?= =?UTF-8?q?aluations=20prend=20en=20compte=20le=20fix=20du=20test=20api=20?= =?UTF-8?q?git=20revert=20du=20revert=20This=20reverts=20commit=20ddfd94b0?= =?UTF-8?q?bab41fd01590b32c915c094dea81f42f.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/assiduites.py | 89 +++++++++++++++ app/scodoc/sco_assiduites.py | 88 +++++++++++++++ tests/api/test_api_assiduites.py | 42 +++++++ tests/unit/test_assiduites.py | 106 ++++++++++++++++++ .../fakedatabase/create_test_api_database.py | 5 +- 5 files changed, 328 insertions(+), 2 deletions(-) diff --git a/app/api/assiduites.py b/app/api/assiduites.py index 7d46ddfe4..503130eda 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -12,6 +12,7 @@ from flask_json import as_json from flask_login import current_user, login_required from flask_sqlalchemy.query import Query from sqlalchemy.orm.exc import ObjectDeletedError +from werkzeug.exceptions import HTTPException from app import db, log, set_sco_dept import app.scodoc.sco_assiduites as scass @@ -21,6 +22,7 @@ from app.api import api_web_bp, get_model_api_object, tools from app.decorators import permission_required, scodoc from app.models import ( Assiduite, + Evaluation, FormSemestre, Identite, ModuleImpl, @@ -282,6 +284,7 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False) 404, message="étudiant inconnu", ) + # Récupération des assiduités de l'étudiant assiduites_query: Query = etud.assiduites @@ -304,6 +307,92 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False) return data_set +@bp.route("/assiduites//evaluations") +@api_web_bp.route("/assiduites//evaluations") +# etudid +@bp.route("/assiduites/etudid//evaluations") +@api_web_bp.route("/assiduites/etudid//evaluations") +# ine +@bp.route("/assiduites/ine//evaluations") +@api_web_bp.route("/assiduites/ine//evaluations") +# nip +@bp.route("/assiduites/nip//evaluations") +@api_web_bp.route("/assiduites/nip//evaluations") +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def assiduites_evaluations(etudid: int = None, nip=None, ine=None): + """ + Retourne la liste de toutes les évaluations de l'étudiant + Pour chaque évaluation, retourne la liste des objets assiduités + sur la plage de l'évaluation + + Présentation du retour : + [ + { + "evaluation_id": 1234, + "assiduites": [ + { + "assiduite_id":1234, + ... + }, + ] + } + ] + + """ + # Récupération de l'étudiant + etud: Identite = tools.get_etud(etudid, nip, ine) + if etud is None: + return json_error( + 404, + message="étudiant inconnu", + ) + + # Récupération des évaluations et des assidiutés + etud_evaluations_assiduites: list[dict] = scass.get_etud_evaluations_assiduites( + etud + ) + + return etud_evaluations_assiduites + + +@api_web_bp.route("/evaluation//assiduites") +@bp.route("/evaluation//assiduites") +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def evaluation_assiduites(evaluation_id): + """ + Retourne les objets assiduités de chaque étudiant sur la plage de l'évaluation + Présentation du retour : + { + "" : [ + { + "assiduite_id":1234, + ... + }, + ] + } + """ + # Récupération de l'évaluation + try: + evaluation: Evaluation = Evaluation.get_evaluation(evaluation_id) + except HTTPException: + return json_error(404, "L'évaluation n'existe pas") + + evaluation_assiduites_par_etudid: dict[int, list[Assiduite]] = {} + for assi in scass.get_evaluation_assiduites(evaluation): + etudid: str = str(assi.etudid) + etud_assiduites = evaluation_assiduites_par_etudid.get(etudid, []) + etud_assiduites.append(assi.to_dict(format_api=True)) + evaluation_assiduites_par_etudid[etudid] = etud_assiduites + + return evaluation_assiduites_par_etudid + + @bp.route("/assiduites/group/query", defaults={"with_query": True}) @api_web_bp.route("/assiduites/group/query", defaults={"with_query": True}) @login_required diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index ff2088fc1..6626c9037 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -10,6 +10,7 @@ from flask_sqlalchemy.query import Query from app import log, db, set_sco_dept from app.models import ( + Evaluation, Identite, FormSemestre, FormSemestreInscription, @@ -731,6 +732,93 @@ def create_absence_billet( return calculator.to_dict()["demi"] +def get_evaluation_assiduites(evaluation: Evaluation) -> Query: + """ + Renvoie une query d'assiduité en fonction des étudiants inscrits à l'évaluation + et de la date de l'évaluation. + + Attention : Si l'évaluation n'a pas de date, renvoie une liste vide + """ + + # Evaluation sans date + if evaluation.date_debut is None: + return [] + + # Récupération des étudiants inscrits à l'évaluation + etuds: Query = Identite.query.join( + ModuleImplInscription, Identite.id == ModuleImplInscription.etudid + ).filter(ModuleImplInscription.moduleimpl_id == evaluation.moduleimpl_id) + + etudids: list[int] = [etud.id for etud in etuds] + + # Récupération des assiduités des étudiants inscrits à l'évaluation + date_debut: datetime = evaluation.date_debut + date_fin: datetime + + if evaluation.date_fin is not None: + date_fin = evaluation.date_fin + else: + # On met à la fin de la journée de date_debut + date_fin = datetime.combine(date_debut.date(), time.max) + + # Filtrage par rapport à la plage de l'évaluation + assiduites: Query = Assiduite.query.filter( + Assiduite.date_debut >= date_debut, + Assiduite.date_fin <= date_fin, + Assiduite.etudid.in_(etudids), + ) + + return assiduites + + +def get_etud_evaluations_assiduites(etud: Identite) -> list[dict]: + """ + Retourne la liste des évaluations d'un étudiant. Pour chaque évaluation, + retourne la liste des assiduités concernant la plage de l'évaluation. + """ + + etud_evaluations_assiduites: list[dict] = [] + + # On récupère les moduleimpls puis les évaluations liés aux moduleimpls + modsimpl_ids: list[int] = [ + modimp_inscr.moduleimpl_id + for modimp_inscr in ModuleImplInscription.query.filter_by(etudid=etud.id) + ] + evaluations: Query = Evaluation.query.filter( + Evaluation.moduleimpl_id.in_(modsimpl_ids) + ) + # Pour chaque évaluation, on récupère l'assiduité de l'étudiant sur la plage + # de l'évaluation + + for evaluation in evaluations: + eval_assis: dict = {"evaluation_id": evaluation.id, "assiduites": []} + # Pas d'assiduités si pas de date + if evaluation.date_debut is not None: + date_debut: datetime = evaluation.date_debut + date_fin: datetime + + if evaluation.date_fin is not None: + date_fin = evaluation.date_fin + else: + # On met à la fin de la journée de date_debut + date_fin = datetime.combine(date_debut.date(), time.max) + + # Filtrage par rapport à la plage de l'évaluation + assiduites: Query = etud.assiduites.filter( + Assiduite.date_debut >= date_debut, + Assiduite.date_fin <= date_fin, + ) + # On récupère les assiduités et on met à jour le dictionnaire + eval_assis["assiduites"] = [ + assi.to_dict(format_api=True) for assi in assiduites + ] + + # On ajoute le dictionnaire à la liste des évaluations + etud_evaluations_assiduites.append(eval_assis) + + return etud_evaluations_assiduites + + # Gestion du cache def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int, int]: """Les comptes d'absences de cet étudiant dans ce semestre: diff --git a/tests/api/test_api_assiduites.py b/tests/api/test_api_assiduites.py index 824fb51a7..059836c83 100644 --- a/tests/api/test_api_assiduites.py +++ b/tests/api/test_api_assiduites.py @@ -43,6 +43,8 @@ ASSIDUITES_FIELDS = { "external_data": dict, } +ASSIDUITES_EVALUATIONS_FIELDS = {"evaluation_id": int, "assiduites": list} + CREATE_FIELD = {"assiduite_id": int} BATCH_FIELD = {"errors": list, "success": list} @@ -139,6 +141,46 @@ def test_route_assiduites(api_headers): check_failure_get(f"/assiduites/{FAUX}/query?", api_headers) +def test_route_assiduites_evaluations(api_headers): + """test de la route /assiduites//evaluations""" + + # Bon fonctionnement + + data = GET( + path=f"/assiduites/{ETUDID}/evaluations", headers=api_headers, dept=DEPT_ACRONYM + ) + assert isinstance(data, list) + for evals in data: + check_fields(evals, ASSIDUITES_EVALUATIONS_FIELDS) + for assi in evals["assiduites"]: + check_fields(assi, ASSIDUITES_FIELDS) + + # Mauvais fonctionnement + check_failure_get(f"/assiduites/{FAUX}/evaluations", api_headers) + + +def test_route_evaluations_assiduites(api_headers): + """test de la route /evaluation//assiduites""" + + # Bon fonctionnement + evaluation_id = 1 + data = GET( + path=f"/evaluation/{evaluation_id}/assiduites", + headers=api_headers, + dept=DEPT_ACRONYM, + ) + assert isinstance(data, dict) + for key, val in data.items(): + assert isinstance(key, str), "Erreur les clés ne sont pas des strings" + assert isinstance(val, list), "Erreur, les valeurs ne sont pas des listes" + + for assi in val: + check_fields(assi, ASSIDUITES_FIELDS) + + # Mauvais fonctionnement + check_failure_get(f"/evaluation/{FAUX}/assiduites", api_headers) + + def test_route_formsemestre_assiduites(api_headers): """test de la route /assiduites/formsemestre/""" diff --git a/tests/unit/test_assiduites.py b/tests/unit/test_assiduites.py index e6e057c87..1ca1579cf 100644 --- a/tests/unit/test_assiduites.py +++ b/tests/unit/test_assiduites.py @@ -7,13 +7,17 @@ ses fonctions liées Ecrit par HARTMANN Matthias (en s'inspirant de tests.unit.test_abs_count.py par Fares Amer ) """ + import pytest +from flask_sqlalchemy.query import Query + import app.scodoc.sco_assiduites as scass import app.scodoc.sco_utils as scu from app import db, log from app.models import ( Assiduite, + Evaluation, FormSemestre, Identite, Justificatif, @@ -1115,6 +1119,7 @@ def _setup_fake_db( "formsemestres": formsemestres, "etuds": etuds, "etud_faux": etud_faux, + "g_fake": g_fake, } @@ -1730,3 +1735,104 @@ def test_cache_assiduites(test_client): ) == (1, 1, 2) # Deuxième semestre 2nj / 1j / 3t (Identique car cache et non modifié) assert scass.get_assiduites_count(etud.id, formsemestre2.to_dict()) == (2, 1, 3) + + +def test_recuperation_evaluations(test_client): + """ + Vérification du bon fonctionnement de la récupération des assiduités d'une évaluation + et de la récupération de l'assiduité aux évaluations d'un étudiant + """ + + data = _setup_fake_db( + [("2024-01-01", "2024-06-30")], + 1, + 1, + ) + moduleimpl: ModuleImpl = data["moduleimpls"][0] + etud: Identite = data["etuds"][0] + + # Création d'assiduités pour tester les évaluations + assiduites_dates = [ + "2024-01-01", + "2024-01-02", + "2024-01-03", + ] + assiduites = [] + for assi in assiduites_dates: + assiduites.append( + Assiduite.create_assiduite( + etud=etud, + date_debut=scu.is_iso_formated(assi + "T10:00", True), + date_fin=scu.is_iso_formated(assi + "T12:00", True), + etat=scu.EtatAssiduite.ABSENT, + ) + ) + + # On génère une évaluation sans date + # elle devrait renvoyer une liste vide + evaluation_1: Evaluation = Evaluation.create( + moduleimpl=moduleimpl, + ) + assert scass.get_evaluation_assiduites(evaluation_1) == [] + + # On génère une évaluation le 01/01/24 de 10h à 12h + evaluation_2: Evaluation = Evaluation.create( + moduleimpl=moduleimpl, + date_debut=scu.is_iso_formated("2024-01-01T10:00", True), + date_fin=scu.is_iso_formated("2024-01-01T12:00", True), + ) + query: Query = scass.get_evaluation_assiduites(evaluation_2) + assert isinstance(query, Query), "Erreur, la fonction ne renvoie pas une Query" + + # On vérifie le contenu de la query + # Cette query devrait contenir que la première assiduité + assert ( + query.count() == 1 + ), "Erreur, la query ne contient pas le bon nombre d'assiduités" + assert ( + query.first() == assiduites[0] + ), "Erreur, la query ne contient pas la bonne assiduité" + + # On génère une évaluation du 02/01/24 au 03/01/24 + evaluation_3: Evaluation = Evaluation.create( + moduleimpl=moduleimpl, + date_debut=scu.is_iso_formated("2024-01-02T10:00", True), + date_fin=scu.is_iso_formated("2024-01-03T12:00", True), + ) + + query: Query = scass.get_evaluation_assiduites(evaluation_3) + assert isinstance(query, Query), "Erreur, la fonction ne renvoie pas une Query" + + # On vérifie le contenu de la query + # On devrait avoir les deux dernières assiduités + assert ( + query.count() == 2 + ), "Erreur, la query ne contient pas le bon nombre d'assiduités" + assert ( + query.all() == assiduites[1:] + ), "Erreur, la query ne contient pas les bonnes assiduités" + + # Test de la récupération des assiduités aux évaluations + evaluations_assiduites = scass.get_etud_evaluations_assiduites(etud) + + assert isinstance( + evaluations_assiduites, list + ), "Erreur, la fonction ne renvoie pas une liste" + assert len(evaluations_assiduites) == 3, "Erreur, le nombre d'évaluations est faux" + assert all( + isinstance(e, dict) for e in evaluations_assiduites + ), "Erreur, les éléments de la liste ne sont pas des dictionnaires" + assert all( + "evaluation_id" in e and "assiduites" in e for e in evaluations_assiduites + ), "Erreur, les dictionnaires ne contiennent pas les bonnes clés" + assert ( + evaluations_assiduites[0]["assiduites"] == [] + ), "Erreur, la première évaluation ne devrait pas contenir d'assiduités" + + assert evaluations_assiduites[1]["assiduites"][0] == assiduites[0].to_dict( + format_api=True + ), "Erreur, la deuxième évaluation n'est pas bonne" + + assert evaluations_assiduites[2]["assiduites"] == [ + assi.to_dict(format_api=True) for assi in assiduites[1:] + ], "Erreur, la troisième évaluation n'est pas bonne" diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py index cd5182bee..adeaa7c33 100644 --- a/tools/fakedatabase/create_test_api_database.py +++ b/tools/fakedatabase/create_test_api_database.py @@ -395,12 +395,13 @@ def ajouter_assiduites_justificatifs(formsemestre: FormSemestre): MODS.append(None) for etud in formsemestre.etuds: + # Se base sur la date des évaluations base_date = datetime.datetime( - 2021, 9, [6, 13, 20, 27][random.randint(0, 3)], 8, 0, 0 + 2022, 3, [1, 8, 15, 22, 29][random.randint(0, 4)], 8, 0, 0 ) base_date = localize_datetime(base_date) - for i in range(random.randint(1, 5)): + for i in range(random.randint(1, 4)): etat = random.randint(0, 2) moduleimpl = random.choice(MODS) deb_date = base_date + datetime.timedelta(days=i) From ac95ff9784285ee3987f8a3ffb659e49dbab653d Mon Sep 17 00:00:00 2001 From: Iziram Date: Wed, 26 Jun 2024 16:27:47 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Assiduit=C3=A9=20:=20ajout=20permission=20"?= =?UTF-8?q?JustifValidate"=20par=20d=C3=A9faut=20(si=20AbsChange)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/versions/60119446aab6_.py | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 migrations/versions/60119446aab6_.py diff --git a/migrations/versions/60119446aab6_.py b/migrations/versions/60119446aab6_.py new file mode 100644 index 000000000..aa0ad778e --- /dev/null +++ b/migrations/versions/60119446aab6_.py @@ -0,0 +1,36 @@ +"""justif_validate_permission + +Revision ID: 60119446aab6 +Revises: 07f37334727b +Create Date: 2024-06-26 16:20:12.283501 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "60119446aab6" +down_revision = "07f37334727b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # ### end Alembic commands ### + # Modification des permissions API + # AbsChange (1 << 13) = 8192 + # JustifValidate (1 << 51 ) = 2251799813685248 + op.execute( + """ + update role set permissions = permissions | 2251799813685248 where (permissions & 8192) <> 0; + """ + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # ### end Alembic commands ### + pass