From 8bc780f2cfe81caf5e343b87b1d0c1a9d8e12bfa Mon Sep 17 00:00:00 2001 From: iziram Date: Wed, 1 Feb 2023 15:08:06 +0100 Subject: [PATCH] =?UTF-8?q?api=20justificatif=20:=20mod=C3=A8le=20+=20api?= =?UTF-8?q?=20(=E2=9D=8C=20archivage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/__init__.py | 1 + app/api/assiduites.py | 52 +-- app/api/justificatif.py | 386 ++++++++++++++++++ app/models/assiduites.py | 50 ++- app/scodoc/sco_archives_justificatifs.py | 90 ++++ app/scodoc/sco_assiduites.py | 48 ++- app/scodoc/sco_utils.py | 7 + ...5e87f_modèles_assiduites_justificatifs.py} | 12 +- tests/api/test_api_justificatifs.py | 255 ++++++++++++ tests/api/test_api_permissions.py | 1 + tests/unit/test_assiduites.py | 35 +- .../fakedatabase/create_test_api_database.py | 34 +- 12 files changed, 907 insertions(+), 64 deletions(-) create mode 100644 app/api/justificatif.py create mode 100644 app/scodoc/sco_archives_justificatifs.py rename migrations/versions/{961b2f2c595d_modeles_assiduites_et_justificatifs.py => dbcf2175e87f_modèles_assiduites_justificatifs.py} (93%) create mode 100644 tests/api/test_api_justificatifs.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 55c708a7a..7c544d631 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -42,6 +42,7 @@ from app.api import ( formations, formsemestres, jury, + justificatif, logos, partitions, users, diff --git a/app/api/assiduites.py b/app/api/assiduites.py index 3fa9587f0..f067641fe 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -6,7 +6,6 @@ """ScoDoc 9 API : Assiduités """ from datetime import datetime -from typing import List from flask import g, jsonify, request from flask_login import login_required @@ -48,7 +47,7 @@ def assiduite(assiduite_id: int = None): data = assiduite_query.to_dict() - return jsonify(change_etat(data)) + return jsonify(_change_etat(data)) @bp.route("/assiduites//count", defaults={"with_query": False}) @@ -104,7 +103,7 @@ def count_assiduites(etudid: int = None, with_query: bool = False): metric: str = "all" if with_query: - metric, filtered = count_manager(request) + metric, filtered = _count_manager(request) return jsonify( scass.get_assiduites_stats( @@ -161,12 +160,12 @@ def assiduites(etudid: int = None, with_query: bool = False): assiduites_query = etud.assiduites if with_query: - assiduites_query = filter_manager(request, assiduites_query) + assiduites_query = _filter_manager(request, assiduites_query) - data_set: List[dict] = [] + data_set: list[dict] = [] for ass in assiduites_query.all(): data = ass.to_dict() - data_set.append(change_etat(data)) + data_set.append(_change_etat(data)) return jsonify(data_set) @@ -199,12 +198,12 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): assiduites_query = scass.filter_by_formsemestre(Assiduite.query, formsemestre) if with_query: - assiduites_query = filter_manager(request, assiduites_query) + assiduites_query = _filter_manager(request, assiduites_query) - data_set: List[dict] = [] + data_set: list[dict] = [] for ass in assiduites_query.all(): data = ass.to_dict() - data_set.append(change_etat(data)) + data_set.append(_change_etat(data)) return jsonify(data_set) @@ -246,7 +245,7 @@ def count_assiduites_formsemestre( metric: str = "all" filtered: dict = {} if with_query: - metric, filtered = count_manager(request) + metric, filtered = _count_manager(request) return jsonify(scass.get_assiduites_stats(assiduites_query, metric, filtered)) @@ -257,7 +256,7 @@ def count_assiduites_formsemestre( @login_required @permission_required(Permission.ScoView) # @permission_required(Permission.ScoAssiduiteChange) -def create(etudid: int = None): +def assiduite_create(etudid: int = None): """ Création d'une assiduité pour l'étudiant (etudid) La requête doit avoir un content type "application/json": @@ -277,9 +276,6 @@ def create(etudid: int = None): ... ] - TODO: - - vérifier si l'entrée est bien une liste - """ etud: Identite = Identite.query.filter_by(id=etudid).first_or_404() @@ -304,7 +300,7 @@ def _create_singular( data: dict, etud: Identite, ) -> tuple[int, object]: - errors: List[str] = [] + errors: list[str] = [] # -- vérifications de l'objet json -- # cas 1 : ETAT @@ -314,7 +310,7 @@ def _create_singular( elif etat not in scu.ETATS_ASSIDUITE: errors.append("param 'etat': invalide") - data = change_etat(data, False) + data = _change_etat(data, False) etat = data.get("etat", None) # cas 2 : date_debut @@ -379,7 +375,7 @@ def _create_singular( @scodoc @permission_required(Permission.ScoView) # @permission_required(Permission.ScoAssiduiteChange) -def delete(): +def assiduite_cdelete(): """ Suppression d'une assiduité à partir de son id @@ -422,7 +418,7 @@ def _delete_singular(assiduite_id: int, database): @scodoc @permission_required(Permission.ScoView) # @permission_required(Permission.ScoAssiduiteChange) -def edit(assiduite_id: int): +def assiduite_cedit(assiduite_id: int): """ Edition d'une assiduité à partir de son id La requête doit avoir un content type "application/json": @@ -435,14 +431,14 @@ def edit(assiduite_id: int): assiduite_unique: Assiduite = Assiduite.query.filter_by( id=assiduite_id ).first_or_404() - errors: List[str] = [] + errors: list[str] = [] data = request.get_json(force=True) # Vérifications de data # Cas 1 : Etat if data.get("etat") is not None: - data = change_etat(data, False) + data = _change_etat(data, False) if data.get("etat") is None: errors.append("param 'etat': invalide") else: @@ -482,7 +478,7 @@ def edit(assiduite_id: int): # -- Utils -- -def change_etat(data: dict, from_int: bool = True): +def _change_etat(data: dict, from_int: bool = True): """change dans un json la valeur du champs état""" if from_int: data["etat"] = scu.ETAT_ASSIDUITE_NAME.get(data["etat"]) @@ -491,7 +487,7 @@ def change_etat(data: dict, from_int: bool = True): return data -def count_manager(requested) -> tuple[str, dict]: +def _count_manager(requested) -> tuple[str, dict]: """ Retourne la/les métriques à utiliser ainsi que le filtre donnés en query de la requête """ @@ -544,28 +540,32 @@ def count_manager(requested) -> tuple[str, dict]: return (metric, filtered) -def filter_manager(requested, assiduites_query): +def _filter_manager(requested, assiduites_query): """ Retourne les assiduites entrées filtrées en fonction de la request """ # cas 1 : etat assiduite etat = requested.args.get("etat") if etat is not None: - assiduites_query = scass.filter_by_etat(assiduites_query, etat) + assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat) # cas 2 : date de début deb = requested.args.get("date_debut") deb: datetime = scu.is_iso_formated(deb, True) if deb is not None: - assiduites_query = scass.filter_by_date(assiduites_query, deb, sup=True) + assiduites_query = scass.filter_assiduites_by_date( + assiduites_query, deb, sup=True + ) # cas 3 : date de fin fin = requested.args.get("date_fin") fin = scu.is_iso_formated(fin, True) if fin is not None: - assiduites_query = scass.filter_by_date(assiduites_query, fin, sup=False) + assiduites_query = scass.filter_assiduites_by_date( + assiduites_query, fin, sup=False + ) # cas 4 : moduleimpl_id module = requested.args.get("moduleimpl_id", False) diff --git a/app/api/justificatif.py b/app/api/justificatif.py new file mode 100644 index 000000000..66e58b9e4 --- /dev/null +++ b/app/api/justificatif.py @@ -0,0 +1,386 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## +"""ScoDoc 9 API : Assiduités +""" +import os +from datetime import datetime + +import app.scodoc.sco_assiduites as scass +import app.scodoc.sco_utils as scu +from app import db + +from app.api import api_bp as bp +from app.api import api_web_bp +from app.scodoc.sco_exceptions import ScoValueError +from app.decorators import permission_required, scodoc +from app.models import Identite, Justificatif +from app.scodoc.sco_archives_justificatifs import JustificatifArchiver +from app.scodoc.sco_permissions import Permission +from flask import g, jsonify, request +from flask_login import login_required +from app.scodoc.sco_utils import json_error + + +# @bp.route("/justificatif/import") +# @api_web_bp.route("/justificatif/import") +# @scodoc +# def justificatif(): +# """ """ +# archiver: JustificatifArchiver = JustificatifArchiver() + +# filename: str = "lol.txt" +# data: bytes = "test".encode("utf-8") + +# archiver.save_justificatif( +# etudid=1, filename=filename, data=data, archive_id="2023-02-01-10-29-20" +# ) +# return jsonify([filename, "done"]) + + +# @bp.route("/justificatif/remove") +# @api_web_bp.route("/justificatif/remove") +# @scodoc +# def justremove(): +# """ """ +# archiver: JustificatifArchiver = JustificatifArchiver() + +# archiver.delete_justificatif(etudid=1, archive_id="2023-02-01-10-29-20") +# return jsonify("done") + +# Partie Modèle +# TODO: justificatif +@bp.route("/justificatif/") +@api_web_bp.route("/assiduite/") +@scodoc +@permission_required(Permission.ScoView) +def justificatif(justif_id: int = None): + """Retourne un objet justificatif à partir de son id + + Exemple de résultat: + { + "justif_id": 1, + "etudid": 2, + "date_debut": "2022-10-31T08:00+01:00", + "date_fin": "2022-10-31T10:00+01:00", + "etat": "valide", + "fichier": "archive_id", + "raison": "une raison", + "entry_date": "2022-10-31T08:00+01:00", + } + """ + + query = Justificatif.query.filter_by(id=justif_id) + if g.scodoc_dept: + query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) + justificatif_unique = query.first_or_404() + + data = justificatif_unique.to_dict() + + return jsonify(_change_etat(data)) + + +# TODO: justificatifs[-query] +@bp.route("/justificatifs/", defaults={"with_query": False}) +@bp.route("/justificatifs//query", defaults={"with_query": True}) +@api_web_bp.route("/justificatifs/", defaults={"with_query": False}) +@api_web_bp.route("/justificatifs//query", defaults={"with_query": True}) +@login_required +@scodoc +@permission_required(Permission.ScoView) +def justificatifs(etudid: int = None, with_query: bool = False): + """ + Retourne toutes les assiduités d'un étudiant + chemin : /justificatifs/ + + Un filtrage peut être donné avec une query + chemin : /justificatifs//query? + + Les différents filtres : + Etat (etat du justificatif -> validé, non validé, modifé, en attente): + query?etat=[- liste des états séparé par une virgule -] + ex: .../query?etat=validé,modifié + Date debut + (date de début du justificatif, sont affichés les justificatifs + dont la date de début est supérieur ou égale à la valeur donnée): + query?date_debut=[- date au format iso -] + ex: query?date_debut=2022-11-03T08:00+01:00 + Date fin + (date de fin du justificatif, sont affichés les justificatifs + dont la date de fin est inférieure ou égale à la valeur donnée): + query?date_fin=[- date au format iso -] + ex: query?date_fin=2022-11-03T10:00+01:00 + """ + + query = Identite.query.filter_by(id=etudid) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + + etud: Identite = query.first_or_404(etudid) + justificatifs_query = etud.justificatifs + + if with_query: + justificatifs_query = _filter_manager(request, justificatifs_query) + + data_set: list[dict] = [] + for just in justificatifs_query.all(): + data = just.to_dict() + data_set.append(_change_etat(data)) + + return jsonify(data_set) + + +# TODO: justificatif-create + + +@bp.route("/justificatif//create", methods=["POST"]) +@api_web_bp.route("/justificatif//create", methods=["POST"]) +@scodoc +@login_required +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +def justif_create(etudid: int = None): + """ + Création d'un justificatif pour l'étudiant (etudid) + La requête doit avoir un content type "application/json": + [ + { + "date_debut": str, + "date_fin": str, + "etat": str, + }, + { + "date_debut": str, + "date_fin": str, + "etat": str, + "raison":str, + } + ... + ] + + """ + etud: Identite = Identite.query.filter_by(id=etudid).first_or_404() + + create_list: list[object] = request.get_json(force=True) + + if not isinstance(create_list, list): + return json_error(404, "Le contenu envoyé n'est pas une liste") + + errors: dict[int, str] = {} + success: dict[int, object] = {} + for i, data in enumerate(create_list): + code, obj = _create_singular(data, etud) + if code == 404: + errors[i] = obj + else: + success[i] = obj + + return jsonify({"errors": errors, "success": success}) + + +def _create_singular( + data: dict, + etud: Identite, +) -> tuple[int, object]: + errors: list[str] = [] + + # -- vérifications de l'objet json -- + # cas 1 : ETAT + etat = data.get("etat", None) + if etat is None: + errors.append("param 'etat': manquant") + elif etat not in scu.ETATS_JUSTIFICATIF: + errors.append("param 'etat': invalide") + + data = _change_etat(data, False) + etat = data.get("etat", None) + + # cas 2 : date_debut + date_debut = data.get("date_debut", None) + if date_debut is None: + errors.append("param 'date_debut': manquant") + deb = scu.is_iso_formated(date_debut, convert=True) + if deb is None: + errors.append("param 'date_debut': format invalide") + + # cas 3 : date_fin + date_fin = data.get("date_fin", None) + if date_fin is None: + errors.append("param 'date_fin': manquant") + fin = scu.is_iso_formated(date_fin, convert=True) + if fin is None: + errors.append("param 'date_fin': format invalide") + + # cas 4 : raison + + raison: str = data.get("raison", None) + + if errors: + err: str = ", ".join(errors) + return (404, err) + + # TOUT EST OK + + try: + nouv_justificatif: Justificatif = Justificatif.create_justificatif( + date_debut=deb, + date_fin=fin, + etat=etat, + etud=etud, + raison=raison, + ) + + db.session.add(nouv_justificatif) + db.session.commit() + return (200, {"justif_id": nouv_justificatif.id}) + except ScoValueError as excp: + return ( + 404, + excp.args[0], + ) + + +# TODO: justificatif-edit +@bp.route("/justificatif//edit", methods=["POST"]) +@api_web_bp.route("/justificatif//edit", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +def justif_edit(justif_id: int): + """ + Edition d'un justificatif à partir de son id + La requête doit avoir un content type "application/json": + { + "etat"?: str, + "raison"?: str + } + """ + justificatif_unique: Justificatif = Justificatif.query.filter_by( + id=justif_id + ).first_or_404() + errors: list[str] = [] + data = request.get_json(force=True) + + # Vérifications de data + + # Cas 1 : Etat + if data.get("etat") is not None: + data = _change_etat(data, False) + if data.get("etat") is None: + errors.append("param 'etat': invalide") + else: + justificatif_unique.etat = data.get("etat") + + # Cas 2 : raison + raison = data.get("raison", False) + if raison is not False: + justificatif_unique.raison = raison + + if errors: + err: str = ", ".join(errors) + return json_error(404, err) + + db.session.add(justificatif_unique) + db.session.commit() + return jsonify({"OK": True}) + + +# TODO: justificatif-delete +@bp.route("/justificatif/delete", methods=["POST"]) +@api_web_bp.route("/justificatif/delete", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +def justif_delete(): + """ + Suppression d'un justificatif à partir de son id + + Forme des données envoyées : + + [ + , + ... + ] + + + """ + justificatifs_list: list[int] = request.get_json(force=True) + if not isinstance(justificatifs_list, list): + return json_error(404, "Le contenu envoyé n'est pas une liste") + + output = {"errors": {}, "success": {}} + + for i, ass in enumerate(justificatifs_list): + code, msg = _delete_singular(ass, db) + if code == 404: + output["errors"][f"{i}"] = msg + else: + output["success"][f"{i}"] = {"OK": True} + db.session.commit() + return jsonify(output) + + +def _delete_singular(justif_id: int, database): + justificatif_unique: Justificatif = Justificatif.query.filter_by( + id=justif_id + ).first() + if justificatif_unique is None: + return (404, "Justificatif non existant") + database.session.delete(justificatif_unique) + return (200, "OK") + + +# Partie archivage +# TODO: justificatif-import +# TODO: justificatif-export +# TODO: justificatif-remove +# TODO: justificatif-list + +# Partie justification +# TODO: justificatif-justified + + +# -- Utils -- +def _change_etat(data: dict, from_int: bool = True): + """change dans un json la valeur du champs état""" + if from_int: + data["etat"] = scu.ETAT_JUSTIFICATIF_NAME.get(data["etat"]) + else: + data["etat"] = scu.ETATS_JUSTIFICATIF.get(data["etat"]) + return data + + +def _filter_manager(requested, justificatifs_query): + """ + Retourne les justificatifs entrés filtrés en fonction de la request + """ + # cas 1 : etat justificatif + etat = requested.args.get("etat") + if etat is not None: + justificatifs_query = scass.filter_justificatifs_by_etat( + justificatifs_query, etat + ) + + # cas 2 : date de début + deb = requested.args.get("date_debut") + deb: datetime = scu.is_iso_formated(deb, True) + if deb is not None: + + justificatifs_query = scass.filter_justificatifs_by_date( + justificatifs_query, deb, sup=True + ) + + # cas 3 : date de fin + fin = requested.args.get("date_fin") + fin = scu.is_iso_formated(fin, True) + + if fin is not None: + justificatifs_query = scass.filter_justificatifs_by_date( + justificatifs_query, fin, sup=False + ) + + return justificatifs_query diff --git a/app/models/assiduites.py b/app/models/assiduites.py index eef7f80f7..ede6aacde 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -7,7 +7,12 @@ from app import db from app.models import ModuleImpl from app.models.etudiants import Identite from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.sco_utils import EtatAssiduite, is_period_overlapping, localize_datetime +from app.scodoc.sco_utils import ( + EtatAssiduite, + EtatJustificatif, + is_period_overlapping, + localize_datetime, +) class Assiduite(db.Model): @@ -123,7 +128,8 @@ class Justificatif(db.Model): __tablename__ = "justificatifs" - justif_id = db.Column(db.Integer, primary_key=True) + id = db.Column(db.Integer, primary_key=True) + justif_id = db.synonym("id") date_debut = db.Column( db.DateTime(timezone=True), server_default=db.func.now(), nullable=False @@ -152,7 +158,7 @@ class Justificatif(db.Model): def to_dict(self) -> dict: data = { - "justif_id": self.assiduite_id, + "justif_id": self.justif_id, "etudid": self.etudid, "date_debut": self.date_debut, "date_fin": self.date_fin, @@ -162,3 +168,41 @@ class Justificatif(db.Model): "entry_date": self.entry_date, } return data + + @classmethod + def create_justificatif( + cls, + etud: Identite, + date_debut: datetime, + date_fin: datetime, + etat: EtatJustificatif, + raison: str = None, + ) -> object or int: + """Créer un nouveau justificatif pour l'étudiant""" + # Vérification de non duplication des périodes + justificatifs: list[Justificatif] = etud.justificatifs.all() + + date_debut = localize_datetime(date_debut) + date_fin = localize_datetime(date_fin) + justificatifs = [ + just + for just in justificatifs + if is_period_overlapping( + (date_debut, date_fin), + (just.date_debut, just.date_fin), + ) + ] + if len(justificatifs) != 0: + raise ScoValueError( + "Duplication des justificatifs (la période rentrée rentre en conflit avec un justificatif enregistré)" + ) + + nouv_assiduite = Justificatif( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudiant=etud, + raison=raison, + ) + + return nouv_assiduite diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py new file mode 100644 index 000000000..dbbefcba8 --- /dev/null +++ b/app/scodoc/sco_archives_justificatifs.py @@ -0,0 +1,90 @@ +from app.scodoc.sco_archives import BaseArchiver +import os + + +class JustificatifArchiver(BaseArchiver): + """ + + TOTALK: + - oid -> etudid + - archive_id -> date de création de l'archive (une archive par dépot de document) + + justificatif + └── + └── + └── + ├── [_description.txt] + └── [] + + + TODO: + - Faire fonction suppression fichier unique dans archive + """ + + def __init__(self): + BaseArchiver.__init__(self, archive_type="justificatifs") + + def save_justificatif( + self, + etudid: int, + filename: str, + data: bytes or str, + archive_id: str = None, + description: str = "", + ): + """ + Ajoute un fichier dans une archive "justificatif" pour l'etudid donné + """ + if archive_id is None: + archive_id: str = self.create_obj_archive( + oid=etudid, description=description + ) + else: + archive_id = self._true_archive_id(archive_id, etudid) + + self.store(archive_id, filename, data) + + def delete_justificatif(self, etudid: int, archive_id: str, filename: str = None): + """ + Supprime une archive ou un fichier particulier de l'archivage de l'étudiant donné + """ + if str(etudid) not in self.list_oids(): + raise ValueError(f"Aucune archive pour etudid[{etudid}]") + + archive_id = self._true_archive_id(archive_id, etudid) + + if filename is not None: + if filename not in self.list_archive(archive_id): + raise ValueError( + f"filename inconnu dans l'archive archive_id[{archive_id}] -> etudid[{etudid}]" + ) + + path: str = os.path.join(self.get_obj_dir(etudid), archive_id, filename) + + if os.path.isfile(path): + os.remove(path) + + else: + self.delete_archive( + os.path.join( + self.get_obj_dir(etudid), + archive_id, + ) + ) + + def _true_archive_id(self, archive_id: str, etudid: int): + """ + Test si l'archive_id est bien dans le dossier d'archive + Retourne le chemin complet de l'id + """ + archives: list[str] = [ + arc for arc in self.list_obj_archives(etudid) if archive_id in arc + ] + + if len(archives) == 0: + raise ValueError( + f"archive_id[{archive_id}] inconnu pour etudid[{etudid}]", + self.list_obj_archives(etudid), + ) + + return archives[0] diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 269e2e8f5..c216184ef 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -1,7 +1,7 @@ from datetime import date, datetime, time, timedelta import app.scodoc.sco_utils as scu -from app.models.assiduites import Assiduite +from app.models.assiduites import Assiduite, Justificatif from app.models.etudiants import Identite from app.models.formsemestre import FormSemestre, FormSemestreInscription @@ -15,11 +15,15 @@ def get_assiduites_stats( if filtered is not None: for key in filtered: if key == "etat": - assiduites = filter_by_etat(assiduites, filtered[key]) + assiduites = filter_assiduites_by_etat(assiduites, filtered[key]) elif key == "date_fin": - assiduites = filter_by_date(assiduites, filtered[key], sup=False) + assiduites = filter_assiduites_by_date( + assiduites, filtered[key], sup=False + ) elif key == "date_debut": - assiduites = filter_by_date(assiduites, filtered[key], sup=True) + assiduites = filter_assiduites_by_date( + assiduites, filtered[key], sup=True + ) elif key == "moduleimpl_id": assiduites = filter_by_module_impl(assiduites, filtered[key]) elif key == "formsemestre": @@ -76,7 +80,7 @@ def get_count(assiduites: Assiduite) -> dict[str, int or float]: return output -def filter_by_etat(assiduites: Assiduite, etat: str) -> Assiduite: +def filter_assiduites_by_etat(assiduites: Assiduite, etat: str) -> Assiduite: """ Filtrage d'une collection d'assiduites en fonction de leur état """ @@ -85,7 +89,7 @@ def filter_by_etat(assiduites: Assiduite, etat: str) -> Assiduite: return assiduites.filter(Assiduite.etat.in_(etats)) -def filter_by_date( +def filter_assiduites_by_date( assiduites: Assiduite, date_: datetime, sup: bool = True ) -> Assiduite: """ @@ -106,6 +110,38 @@ def filter_by_date( return assiduites.filter(Assiduite.date_fin <= date_) +def filter_justificatifs_by_etat( + justificatifs: Justificatif, etat: str +) -> Justificatif: + """ + Filtrage d'une collection de justificatifs en fonction de leur état + """ + etats: list[str] = list(etat.split(",")) + etats = [scu.ETATS_JUSTIFICATIF.get(e, -1) for e in etats] + return justificatifs.filter(Justificatif.etat.in_(etats)) + + +def filter_justificatifs_by_date( + justificatifs: Justificatif, date_: datetime, sup: bool = True +) -> Assiduite: + """ + Filtrage d'une collection d'assiduites en fonction d'une date + + Sup == True -> les assiduites doivent débuter après 'date'\n + Sup == False -> les assiduites doivent finir avant 'date' + """ + + if date_.tzinfo is None: + first_justificatif: Justificatif = justificatifs.first() + if first_justificatif is not None: + date_: datetime = date_.replace(tzinfo=first_justificatif.date_debut.tzinfo) + + if sup: + return justificatifs.filter(Justificatif.date_debut >= date_) + + return justificatifs.filter(Justificatif.date_fin <= date_) + + def filter_by_module_impl( assiduites: Assiduite, module_impl_id: int or None ) -> Assiduite: diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index ae178236a..69a115d88 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -128,6 +128,13 @@ ETAT_JUSTIFICATIF_NAME = { EtatJustificatif.MODIFIE: "modifié", } +ETATS_JUSTIFICATIF = { + "validé": EtatJustificatif.VALIDE, + "non vaidé": EtatJustificatif.NON_VALIDE, + "en attente": EtatJustificatif.ATTENTE, + "modifié": EtatJustificatif.MODIFIE, +} + def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: """ diff --git a/migrations/versions/961b2f2c595d_modeles_assiduites_et_justificatifs.py b/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py similarity index 93% rename from migrations/versions/961b2f2c595d_modeles_assiduites_et_justificatifs.py rename to migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py index a3c49c147..7b57ccbfb 100644 --- a/migrations/versions/961b2f2c595d_modeles_assiduites_et_justificatifs.py +++ b/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py @@ -1,8 +1,8 @@ -"""Modeles assiduites et justificatifs +"""modèles assiduites justificatifs -Revision ID: 961b2f2c595d +Revision ID: dbcf2175e87f Revises: 5c7b208355df -Create Date: 2023-01-31 15:37:02.961533 +Create Date: 2023-02-01 14:21:06.989190 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '961b2f2c595d' +revision = 'dbcf2175e87f' down_revision = '5c7b208355df' branch_labels = None depends_on = None @@ -19,7 +19,7 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('justificatifs', - sa.Column('justif_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), sa.Column('date_debut', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('date_fin', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('etudid', sa.Integer(), nullable=False), @@ -28,7 +28,7 @@ def upgrade(): sa.Column('raison', sa.Text(), nullable=True), sa.Column('fichier', sa.Text(), nullable=True), sa.ForeignKeyConstraint(['etudid'], ['identite.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('justif_id') + sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_justificatifs_etudid'), 'justificatifs', ['etudid'], unique=False) op.create_table('assiduites', diff --git a/tests/api/test_api_justificatifs.py b/tests/api/test_api_justificatifs.py new file mode 100644 index 000000000..0c1ce6adb --- /dev/null +++ b/tests/api/test_api_justificatifs.py @@ -0,0 +1,255 @@ +""" +Test de l'api Assiduité + +Ecrit par HARTMANN Matthias + +""" + +from random import randint + +from tests.api.setup_test_api import GET, POST_JSON, APIError, api_headers + +ETUDID = 1 +FAUX = 42069 + + +JUSTIFICATIFS_FIELDS = { + "justif_id": int, + "etudid": int, + "date_debut": str, + "date_fin": str, + "etat": str, + "raison": str, + "entry_date": str, + "fichier": str, +} + +CREATE_FIELD = {"justif_id": int} +BATCH_FIELD = {"errors": dict, "success": dict} + +TO_REMOVE = [] + + +def check_fields(data, fields: dict = None): + if fields is None: + fields = JUSTIFICATIFS_FIELDS + assert set(data.keys()) == set(fields.keys()) + for key in data: + if key in ("raison", "fichier"): + assert isinstance(data[key], fields[key]) or data[key] is None + else: + assert isinstance(data[key], fields[key]) + + +def check_failure_get(path, headers, err=None): + try: + GET(path=path, headers=headers) + # ^ Renvoi un 404 + except APIError as api_err: + if err is not None: + assert api_err.payload["message"] == err + else: + raise APIError("Le GET n'aurait pas du fonctionner") + + +def check_failure_post(path, headers, data, err=None): + try: + data = POST_JSON(path=path, headers=headers, data=data) + # ^ Renvoi un 404 + except APIError as api_err: + if err is not None: + assert api_err.payload["message"] == err + else: + raise APIError("Le GET n'aurait pas du fonctionner") + + +def create_data(etat: str, day: str, raison: str = None): + data = { + "date_debut": f"2022-01-{day}T08:00", + "date_fin": f"2022-01-{day}T10:00", + "etat": etat, + } + if raison is not None: + data["desc"] = raison + + return data + + +def test_route_justificatif(api_headers): + + # Bon fonctionnement == id connu + data = GET(path="/justificatif/1", headers=api_headers) + check_fields(data) + + # Mauvais Fonctionnement == id inconnu + + check_failure_get( + f"/justificatif/{FAUX}", + api_headers, + ) + + +def test_route_justificatifs(api_headers): + + # Bon fonctionnement + + data = GET(path=f"/justificatifs/{ETUDID}", headers=api_headers) + assert isinstance(data, list) + for just in data: + check_fields(just, JUSTIFICATIFS_FIELDS) + + data = GET(path=f"/justificatifs/{ETUDID}/query?", headers=api_headers) + assert isinstance(data, list) + for just in data: + check_fields(just, JUSTIFICATIFS_FIELDS) + + # Mauvais fonctionnement + check_failure_get(f"/justificatifs/{FAUX}", api_headers) + check_failure_get(f"/justificatifs/{FAUX}/query?", api_headers) + + +def test_route_create(api_headers): + + # -== Unique ==- + + # Bon fonctionnement + data = create_data("validé", "01") + + res = POST_JSON(f"/justificatif/{ETUDID}/create", [data], api_headers) + check_fields(res, BATCH_FIELD) + assert len(res["success"]) == 1 + + TO_REMOVE.append(res["success"]["0"]["justif_id"]) + + data2 = create_data("modifié", "02", "raison") + res = POST_JSON(f"/justificatif/{ETUDID}/create", [data2], api_headers) + check_fields(res, BATCH_FIELD) + assert len(res["success"]) == 1 + + TO_REMOVE.append(res["success"]["0"]["justif_id"]) + + # Mauvais fonctionnement + check_failure_post(f"/justificatif/{FAUX}/create", api_headers, [data]) + + res = POST_JSON(f"/justificatif/{ETUDID}/create", [data], api_headers) + check_fields(res, BATCH_FIELD) + assert len(res["errors"]) == 1 + assert ( + res["errors"]["0"] + == "Duplication des justificatifs (la période rentrée rentre en conflit avec un justificatif enregistré)" + ) + + res = POST_JSON( + f"/justificatif/{ETUDID}/create", + [create_data("absent", "03")], + api_headers, + ) + check_fields(res, BATCH_FIELD) + assert len(res["errors"]) == 1 + assert res["errors"]["0"] == "param 'etat': invalide" + + # -== Multiple ==- + + # Bon Fonctionnement + + etats = ["validé", "modifé", "non validé", "en attente"] + data = [ + create_data(etats[d % 4], 10 + d, "raison" if d % 2 else None) + for d in range(randint(3, 5)) + ] + + res = POST_JSON(f"/justificatif/{ETUDID}/create", data, api_headers) + check_fields(res, BATCH_FIELD) + for dat in res["success"]: + check_fields(res["success"][dat], CREATE_FIELD) + TO_REMOVE.append(res["success"][dat]["justif_id"]) + + # Mauvais Fonctionnement + + data2 = [ + create_data("modifié", "01"), + create_data(None, "25"), + create_data("blabla", 26), + create_data("validé", 32), + ] + + res = POST_JSON(f"/justificatif/{ETUDID}/create", data2, api_headers) + check_fields(res, BATCH_FIELD) + assert len(res["errors"]) == 4 + + assert ( + res["errors"]["0"] + == "Duplication des justificatifs (la période rentrée rentre en conflit avec un justificatif enregistré)" + ) + assert res["errors"]["1"] == "param 'etat': manquant" + assert res["errors"]["2"] == "param 'etat': invalide" + assert ( + res["errors"]["3"] + == "param 'date_debut': format invalide, param 'date_fin': format invalide" + ) + + +def test_route_edit(api_headers): + + # Bon fonctionnement + + data = {"etat": "modifié", "raison": "test"} + res = POST_JSON(f"/justificatif/{TO_REMOVE[0]}/edit", data, api_headers) + assert res == {"OK": True} + + data["raison"] = None + res = POST_JSON(f"/justificatif/{TO_REMOVE[1]}/edit", data, api_headers) + assert res == {"OK": True} + + # Mauvais fonctionnement + + check_failure_post(f"/justificatif/{FAUX}/edit", api_headers, data) + data["etat"] = "blabla" + check_failure_post( + f"/justificatif/{TO_REMOVE[2]}/edit", + api_headers, + data, + err="param 'etat': invalide", + ) + + +def test_route_delete(api_headers): + # -== Unique ==- + + # Bon fonctionnement + data = TO_REMOVE[0] + + res = POST_JSON("/justificatif/delete", [data], api_headers) + check_fields(res, BATCH_FIELD) + for dat in res["success"]: + assert res["success"][dat] == {"OK": True} + + # Mauvais fonctionnement + res = POST_JSON("/justificatif/delete", [data], api_headers) + check_fields(res, BATCH_FIELD) + assert len(res["errors"]) == 1 + + # -== Multiple ==- + + # Bon Fonctionnement + + data = TO_REMOVE[1:] + + res = POST_JSON("/justificatif/delete", data, api_headers) + check_fields(res, BATCH_FIELD) + for dat in res["success"]: + assert res["success"][dat] == {"OK": True} + + # Mauvais Fonctionnement + + data2 = [ + FAUX, + FAUX + 1, + FAUX + 2, + ] + + res = POST_JSON("/justificatif/delete", data2, api_headers) + check_fields(res, BATCH_FIELD) + assert len(res["errors"]) == 3 + + assert all([res["errors"][i] == "Justificatif non existant" for i in res["errors"]]) diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index dd66d6dae..ee8557647 100644 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -60,6 +60,7 @@ def test_permissions(api_headers): "uid": 1, "version": "long", "assiduite_id": 1, + "justif_id": 1, } for rule in api_rules: path = rule.build(args)[1] diff --git a/tests/unit/test_assiduites.py b/tests/unit/test_assiduites.py index 67dc0a982..42b80b3f2 100644 --- a/tests/unit/test_assiduites.py +++ b/tests/unit/test_assiduites.py @@ -142,7 +142,7 @@ def editer_supprimer_assiduiter(etuds: list[Identite], moduleimpls: list[int]): # Vérification du changement assert ( - scass.filter_by_etat(etuds[0].assiduites, "retard").count() == 3 + scass.filter_assiduites_by_etat(etuds[0].assiduites, "retard").count() == 3 ), "Edition d'assiduité mauvais" assert ( scass.filter_by_module_impl(etuds[1].assiduites, moduleimpls[0].id).count() == 2 @@ -294,22 +294,25 @@ def verifier_comptage_et_filtrage( # Etat assert ( - scass.filter_by_etat(etu2.assiduites, "present").count() == 2 + scass.filter_assiduites_by_etat(etu2.assiduites, "present").count() == 2 ), "Filtrage de l'état 'présent' mauvais" assert ( - scass.filter_by_etat(etu2.assiduites, "retard").count() == 2 + scass.filter_assiduites_by_etat(etu2.assiduites, "retard").count() == 2 ), "Filtrage de l'état 'retard' mauvais" assert ( - scass.filter_by_etat(etu2.assiduites, "absent").count() == 2 + scass.filter_assiduites_by_etat(etu2.assiduites, "absent").count() == 2 ), "Filtrage de l'état 'absent' mauvais" assert ( - scass.filter_by_etat(etu2.assiduites, "absent,retard").count() == 4 + scass.filter_assiduites_by_etat(etu2.assiduites, "absent,retard").count() == 4 ), "Filtrage de l'état 'absent,retard' mauvais" assert ( - scass.filter_by_etat(etu2.assiduites, "absent,retard,present").count() == 6 + scass.filter_assiduites_by_etat( + etu2.assiduites, "absent,retard,present" + ).count() + == 6 ), "Filtrage de l'état 'absent,retard,present' mauvais" assert ( - scass.filter_by_etat(etu2.assiduites, "autre").count() == 0 + scass.filter_assiduites_by_etat(etu2.assiduites, "autre").count() == 0 ), "Filtrage de l'état 'autre' mauvais" # Module @@ -349,39 +352,39 @@ def verifier_comptage_et_filtrage( # Date début date = scu.localize_datetime("2022-09-01T10:00+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=True).count() == 6 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=True).count() == 6 ), "Filtrage 'Date début' mauvais" date = scu.localize_datetime("2022-09-03T10:00:00+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=True).count() == 5 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=True).count() == 5 ), "Filtrage 'Date début' mauvais" date = scu.localize_datetime("2022-09-03T10:00:01+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=True).count() == 5 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=True).count() == 5 ), "Filtrage 'Date début' mauvais" date = scu.localize_datetime("2022-09-03T10:00:02+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=True).count() == 4 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=True).count() == 4 ), "Filtrage 'Date début' mauvais" # Date fin date = scu.localize_datetime("2022-09-01T10:00+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=False).count() == 0 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=False).count() == 0 ), "Filtrage 'Date fin' mauvais" date = scu.localize_datetime("2022-09-03T10:00:00+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=False).count() == 1 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=False).count() == 1 ), "Filtrage 'Date fin' mauvais" date = scu.localize_datetime("2022-09-03T10:00:01+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=False).count() == 1 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=False).count() == 1 ), "Filtrage 'Date fin' mauvais" date = scu.localize_datetime("2023-01-04T13:00:01+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=False).count() == 6 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=False).count() == 6 ), "Filtrage 'Date fin' mauvais" date = scu.localize_datetime("2023-01-03T11:00:01+01:00") assert ( - scass.filter_by_date(etu2.assiduites, date, sup=False).count() == 4 + scass.filter_assiduites_by_date(etu2.assiduites, date, sup=False).count() == 4 ), "Filtrage 'Date fin' mauvais" diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py index f884b82de..ef6d0fe87 100644 --- a/tools/fakedatabase/create_test_api_database.py +++ b/tools/fakedatabase/create_test_api_database.py @@ -11,14 +11,12 @@ import datetime import os import random import shutil -import time import sys +import time -from app import db +from app import db, models from app.auth.models import Role, User from app.but.import_refcomp import orebut_import_refcomp -from app import models -from app.models import departements from app.models import ( Absence, Assiduite, @@ -27,8 +25,10 @@ from app.models import ( FormSemestre, FormSemestreEtape, Identite, + Justificatif, ModuleImpl, NotesNotes, + departements, ) from app.scodoc import ( sco_cache, @@ -38,6 +38,7 @@ from app.scodoc import ( sco_groups, ) from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import localize_datetime from tools.fakeportal.gen_nomprenoms import nomprenom random.seed(12345678) # tests reproductibles @@ -379,13 +380,12 @@ def create_logos(): ) -def ajouter_assiduites(formsemestre: FormSemestre): +def ajouter_assiduites_justificatifs(formsemestre: FormSemestre): """ Ajoute des assiduités semi-aléatoires à chaque étudiant du semestre """ MODS = [moduleimpl for moduleimpl in formsemestre.modimpls] MODS.append(None) - from app.scodoc.sco_utils import localize_datetime for etud in formsemestre.etuds: @@ -408,6 +408,26 @@ def ajouter_assiduites(formsemestre: FormSemestre): db.session.add(code) + for i in range(random.randint(0, 2)): + etat = random.randint(0, 3) + deb_date = base_date + datetime.timedelta(days=i) + fin_date = deb_date + datetime.timedelta(hours=8) + raison = random.choice(["raison", None]) + + code = Justificatif.create_justificatif( + etud=etud, + date_debut=deb_date, + date_fin=fin_date, + etat=etat, + raison=raison, + ) + + assert isinstance( + code, Justificatif + ), "Erreur dans la génération des justificatifs" + + db.session.add(code) + db.session.commit() @@ -431,7 +451,7 @@ def init_test_database(): saisie_notes_evaluations(formsemestre, user_lecteur) add_absences(formsemestre) create_etape_apo(formsemestre) - ajouter_assiduites(formsemestre) + ajouter_assiduites_justificatifs(formsemestre) create_logos() # à compléter # - groupes