From 1d3726a4cde69fd23f784bf82820b12f6c72e541 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 26 Aug 2023 16:34:56 +0200 Subject: [PATCH] API evaluation: create avec poids, /delete + tests unitaires + corrections --- .pylintrc | 2 +- app/api/__init__.py | 15 ++++- app/api/evaluations.py | 57 +++++++++++++---- app/api/formations.py | 50 --------------- app/api/formsemestres.py | 2 +- app/api/moduleimpl.py | 69 ++++++++++++++++++++ app/comp/res_but.py | 4 +- app/comp/res_classic.py | 4 +- app/models/evaluations.py | 23 ++++--- app/models/modules.py | 4 ++ app/pe/pe_view.py | 6 +- app/scodoc/sco_cache.py | 6 +- app/scodoc/sco_evaluation_db.py | 2 +- app/scodoc/sco_saisie_notes.py | 19 +++--- app/templates/but/parcour_formation.j2 | 2 +- tests/api/test_api_evaluations.py | 89 ++++++++++++++++++++++++-- tools/test_api.sh | 74 ++++++++++++++------- 17 files changed, 304 insertions(+), 124 deletions(-) create mode 100644 app/api/moduleimpl.py diff --git a/.pylintrc b/.pylintrc index 928e63db..04cad358 100644 --- a/.pylintrc +++ b/.pylintrc @@ -20,5 +20,5 @@ ignored-classes=Permission, # supports qualified module names, as well as Unix pattern matching. ignored-modules=entreprises -good-names=d,e,f,i,j,k,nt,t,u,ue,v,x,y,z,H,F +good-names=d,e,f,i,j,k,n,nt,t,u,ue,v,x,y,z,H,F diff --git a/app/api/__init__.py b/app/api/__init__.py index bb8f6cc5..e2d7a95f 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -5,7 +5,7 @@ from flask import Blueprint from flask import request, g from app import db from app.scodoc import sco_utils as scu -from app.scodoc.sco_exceptions import ScoException +from app.scodoc.sco_exceptions import AccessDenied, ScoException api_bp = Blueprint("api", __name__) api_web_bp = Blueprint("apiweb", __name__) @@ -15,12 +15,24 @@ API_CLIENT_ERROR = 400 # erreur dans les paramètres fournis par le client @api_bp.errorhandler(ScoException) +@api_web_bp.errorhandler(ScoException) @api_bp.errorhandler(404) def api_error_handler(e): "erreurs API => json" return scu.json_error(404, message=str(e)) +@api_bp.errorhandler(AccessDenied) +@api_web_bp.errorhandler(AccessDenied) +def permission_denied_error_handler(exc): + """ + Renvoie message d'erreur pour l'erreur 403 + """ + return scu.json_error( + 403, f"operation non autorisee ({exc.args[0] if exc.args else ''})" + ) + + def requested_format(default_format="json", allowed_formats=None): """Extract required format from query string. * default value is json. A list of allowed formats may be provided @@ -65,6 +77,7 @@ from app.api import ( jury, justificatifs, logos, + moduleimpl, partitions, semset, users, diff --git a/app/api/evaluations.py b/app/api/evaluations.py index 9f9892a8..6643ea84 100644 --- a/app/api/evaluations.py +++ b/app/api/evaluations.py @@ -12,7 +12,7 @@ from flask_json import as_json from flask_login import current_user, login_required import app -from app import db +from app import log, db from app.api import api_bp as bp, api_web_bp from app.decorators import scodoc, permission_required from app.models import Evaluation, ModuleImpl, FormSemestre @@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu @scodoc @permission_required(Permission.ScoView) @as_json -def evaluation(evaluation_id: int): +def get_evaluation(evaluation_id: int): """Description d'une évaluation. { @@ -203,13 +203,7 @@ def evaluation_create(moduleimpl_id: int): "visibulletin" : boolean , //default true "publish_incomplete" : boolean , //default false "coefficient" : float, // si non spécifié, 1.0 - "poids" : [ { - "ue_id": int, - "poids": float - }, - ... - ] // si non spécifié, tous les poids à 1.0 - } + "poids" : { ue_id : poids } // optionnel } Result: l'évaluation créée. """ @@ -220,8 +214,6 @@ def evaluation_create(moduleimpl_id: int): try: evaluation = Evaluation.create(moduleimpl=moduleimpl, **data) - except AccessDenied: - return scu.json_error(403, "opération non autorisée (2)") except ValueError: return scu.json_error(400, "paramètre incorrect") except ScoValueError as exc: @@ -231,6 +223,27 @@ def evaluation_create(moduleimpl_id: int): db.session.add(evaluation) db.session.commit() + # Les poids vers les UEs: + poids = data.get("poids") + if poids is not None: + if not isinstance(poids, dict): + log("API error: canceling evaluation creation") + db.session.delete(evaluation) + db.session.commit() + return scu.json_error( + 400, "paramètre de type incorrect (poids must be a dict)" + ) + try: + evaluation.set_ue_poids_dict(data["poids"]) + except ScoValueError as exc: + log("API error: canceling evaluation creation") + db.session.delete(evaluation) + db.session.commit() + return scu.json_error( + 400, + f"erreur enregistrement des poids ({exc.args[0] if exc.args else ''})", + ) + db.session.commit() return evaluation.to_dict_api() @@ -241,4 +254,24 @@ def evaluation_create(moduleimpl_id: int): @permission_required(Permission.ScoEnsView) # permission gérée dans la fonction @as_json def evaluation_delete(evaluation_id: int): - pass + """Suppression d'une évaluation. + Efface aussi toutes ses notes + """ + query = Evaluation.query.filter_by(id=evaluation_id) + if g.scodoc_dept: + query = ( + query.join(ModuleImpl) + .join(FormSemestre) + .filter_by(dept_id=g.scodoc_dept_id) + ) + evaluation = query.first_or_404() + dept = evaluation.moduleimpl.formsemestre.departement + app.set_sco_dept(dept.acronym) + if not evaluation.moduleimpl.can_edit_evaluation(current_user): + raise AccessDenied("evaluation_delete") + + sco_saisie_notes.evaluation_suppress_alln( + evaluation_id=evaluation_id, dialog_confirmed=True + ) + sco_evaluation_db.do_evaluation_delete(evaluation_id) + return "ok" diff --git a/app/api/formations.py b/app/api/formations.py index 2712a2c2..e8a145a5 100644 --- a/app/api/formations.py +++ b/app/api/formations.py @@ -21,8 +21,6 @@ from app.models import ( ApcNiveau, ApcParcours, Formation, - FormSemestre, - ModuleImpl, UniteEns, ) from app.scodoc import sco_formations @@ -249,54 +247,6 @@ def referentiel_competences(formation_id: int): return formation.referentiel_competence.to_dict() -@bp.route("/moduleimpl/") -@api_web_bp.route("/moduleimpl/") -@login_required -@scodoc -@permission_required(Permission.ScoView) -@as_json -def moduleimpl(moduleimpl_id: int): - """ - Retourne un moduleimpl en fonction de son id - - moduleimpl_id : l'id d'un moduleimpl - - Exemple de résultat : - { - "id": 1, - "formsemestre_id": 1, - "module_id": 1, - "responsable_id": 2, - "moduleimpl_id": 1, - "ens": [], - "module": { - "heures_tp": 0, - "code_apogee": "", - "titre": "Initiation aux réseaux informatiques", - "coefficient": 1, - "module_type": 2, - "id": 1, - "ects": null, - "abbrev": "Init aux réseaux informatiques", - "ue_id": 1, - "code": "R101", - "formation_id": 1, - "heures_cours": 0, - "matiere_id": 1, - "heures_td": 0, - "semestre_id": 1, - "numero": 10, - "module_id": 1 - } - } - """ - query = ModuleImpl.query.filter_by(id=moduleimpl_id) - if g.scodoc_dept: - query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) - modimpl: ModuleImpl = query.first_or_404() - return modimpl.to_dict(convert_objects=True) - - @bp.route("/set_ue_parcours/", methods=["POST"]) @api_web_bp.route("/set_ue_parcours/", methods=["POST"]) @login_required diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index d4954566..40fc81bb 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -212,7 +212,7 @@ def bulletins(formsemestre_id: int, version: str = "long"): @as_json def formsemestre_programme(formsemestre_id: int): """ - Retourne la liste des Ues, ressources et SAE d'un semestre + Retourne la liste des UEs, ressources et SAEs d'un semestre formsemestre_id : l'id d'un formsemestre diff --git a/app/api/moduleimpl.py b/app/api/moduleimpl.py new file mode 100644 index 00000000..b8e87c05 --- /dev/null +++ b/app/api/moduleimpl.py @@ -0,0 +1,69 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +""" + ScoDoc 9 API : accès aux moduleimpl +""" + +from flask import g +from flask_json import as_json +from flask_login import login_required + +from app.api import api_bp as bp, api_web_bp +from app.decorators import scodoc, permission_required +from app.models import ( + FormSemestre, + ModuleImpl, +) +from app.scodoc.sco_permissions import Permission + + +@bp.route("/moduleimpl/") +@api_web_bp.route("/moduleimpl/") +@login_required +@scodoc +@permission_required(Permission.ScoView) +@as_json +def moduleimpl(moduleimpl_id: int): + """ + Retourne un moduleimpl en fonction de son id + + moduleimpl_id : l'id d'un moduleimpl + + Exemple de résultat : + { + "id": 1, + "formsemestre_id": 1, + "module_id": 1, + "responsable_id": 2, + "moduleimpl_id": 1, + "ens": [], + "module": { + "heures_tp": 0, + "code_apogee": "", + "titre": "Initiation aux réseaux informatiques", + "coefficient": 1, + "module_type": 2, + "id": 1, + "ects": null, + "abbrev": "Init aux réseaux informatiques", + "ue_id": 1, + "code": "R101", + "formation_id": 1, + "heures_cours": 0, + "matiere_id": 1, + "heures_td": 0, + "semestre_id": 1, + "numero": 10, + "module_id": 1 + } + } + """ + query = ModuleImpl.query.filter_by(id=moduleimpl_id) + if g.scodoc_dept: + query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + modimpl: ModuleImpl = query.first_or_404() + return modimpl.to_dict(convert_objects=True) diff --git a/app/comp/res_but.py b/app/comp/res_but.py index f7934172..238f38d5 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -53,8 +53,8 @@ class ResultatsSemestreBUT(NotesTableCompat): self.store() t2 = time.time() log( - f"""ResultatsSemestreBUT: cached formsemestre_id={formsemestre.id - } ({(t1-t0):g}s +{(t2-t1):g}s)""" + f"""+++ ResultatsSemestreBUT: cached [{formsemestre.id + }] ({(t1-t0):g}s +{(t2-t1):g}s) +++""" ) def compute(self): diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 268ea0fb..50976668 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -50,8 +50,8 @@ class ResultatsSemestreClassic(NotesTableCompat): self.store() t2 = time.time() log( - f"""ResultatsSemestreClassic: cached formsemestre_id={ - formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)""" + f"""+++ ResultatsSemestreClassic: cached formsemestre_id={ + formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s) +++""" ) # recalculé (aussi rapide que de les cacher) self.moy_min = self.etud_moy_gen.min() diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 24990080..235b7f74 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -9,7 +9,7 @@ from flask import g, url_for from flask_login import current_user import sqlalchemy as sa -from app import db +from app import db, log from app.models.etudiants import Identite from app.models.events import ScolarNews from app.models.moduleimpls import ModuleImpl @@ -79,7 +79,9 @@ class Evaluation(db.Model): numero=None, **kw, # ceci pour absorber les éventuel arguments excedentaires ): - """Create an evaluation. Check permission and all arguments.""" + """Create an evaluation. Check permission and all arguments. + Ne crée pas les poids vers les UEs. + """ if not moduleimpl.can_edit_evaluation(current_user): raise AccessDenied( f"Modification évaluation impossible pour {current_user.get_nomplogin()}" @@ -100,11 +102,12 @@ class Evaluation(db.Model): scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl.id, ) + log(f"created evaluation in {moduleimpl.module.titre_str()}") ScolarNews.add( typ=ScolarNews.NEWS_NOTE, obj=moduleimpl.id, text=f"""Création d'une évaluation dans { - moduleimpl.module.titre or '(module sans titre)'}""", + moduleimpl.module.titre_str()}""", url=url, ) return evaluation @@ -275,17 +278,17 @@ class Evaluation(db.Model): return f"{dt.hour}h" if self.date_fin is None: - return ( - f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut())}" - ) + return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" if self.date_debut.date() == self.date_fin.date(): # même jour if self.date_debut.time() == self.date_fin.time(): - return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut())}" + return ( + f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" + ) return f"""le {self.date_debut.strftime('%d/%m/%Y')} de { - _h(self.date_debut())} à {_h(self.date_fin())}""" + _h(self.date_debut)} à {_h(self.date_fin)}""" # évaluation sur plus d'une journée return f"""du {self.date_debut.strftime('%d/%m/%Y')} à { - _h(self.date_debut())} au {self.date_fin.strftime('%d/%m/%Y')} à {_h(self.date_fin())}""" + _h(self.date_debut)} au {self.date_fin.strftime('%d/%m/%Y')} à {_h(self.date_fin)}""" def heure_debut(self) -> str: """L'heure de début (sans la date), en ISO. @@ -356,6 +359,8 @@ class Evaluation(db.Model): L = [] for ue_id, poids in ue_poids_dict.items(): ue = db.session.get(UniteEns, ue_id) + if ue is None: + raise ScoValueError("poids vers une UE inexistante") ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids) L.append(ue_poids) db.session.add(ue_poids) diff --git a/app/models/modules.py b/app/models/modules.py index 6aaaef19..9fafe5cb 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -153,6 +153,10 @@ class Module(db.Model): """ return scu.ModuleType.get_abbrev(self.module_type) + def titre_str(self) -> str: + "Identifiant du module à afficher : abbrev ou titre ou code" + return self.abbrev or self.titre or self.code + def sort_key_apc(self) -> tuple: """Clé de tri pour avoir présentation par type (res, sae), parcours, type, numéro diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index bb5f386f..8c2a40b7 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -57,8 +57,10 @@ def _pe_view_sem_recap_form(formsemestre_id): poursuites d'études.
De nombreux aspects sont paramétrables: - - voir la documentation. + + voir la documentation + .

diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index 31af42eb..245389ba 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -273,9 +273,7 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa if formsemestre_id is None: # clear all caches - log( - f"----- invalidate_formsemestre: clearing all caches. pdfonly={pdfonly}-----" - ) + log(f"--- invalidate_formsemestre: clearing all caches. pdfonly={pdfonly}---") formsemestre_ids = [ formsemestre.id for formsemestre in FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id) @@ -285,7 +283,7 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa formsemestre_id ] + sco_cursus.list_formsemestre_utilisateurs_uecap(formsemestre_id) log( - f"----- invalidate_formsemestre: clearing {formsemestre_ids}. pdfonly={pdfonly} -----" + f"--- invalidate_formsemestre: clearing {formsemestre_ids}. pdfonly={pdfonly} ---" ) if not pdfonly: diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py index d7b7b7ed..041daf34 100644 --- a/app/scodoc/sco_evaluation_db.py +++ b/app/scodoc/sco_evaluation_db.py @@ -133,7 +133,7 @@ def do_evaluation_delete(evaluation_id): raise ScoValueError( "Impossible de supprimer cette évaluation: il reste des notes" ) - + log(f"deleting evaluation {evaluation}") db.session.delete(evaluation) db.session.commit() diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index d9977b99..d814c3c6 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -502,15 +502,16 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False): """ ] # news - ScolarNews.add( - typ=ScolarNews.NEWS_NOTE, - obj=evaluation.moduleimpl.id, - text=f"""Suppression des notes d'une évaluation dans - {evaluation.moduleimpl.module.titre or 'module sans titre'} - """, - url=status_url, - ) + if nb_suppress: + ScolarNews.add( + typ=ScolarNews.NEWS_NOTE, + obj=evaluation.moduleimpl.id, + text=f"""Suppression des notes d'une évaluation dans + {evaluation.moduleimpl.module.titre or 'module sans titre'} + """, + url=status_url, + ) return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer() diff --git a/app/templates/but/parcour_formation.j2 b/app/templates/but/parcour_formation.j2 index 84aa11f0..f040d711 100644 --- a/app/templates/but/parcour_formation.j2 +++ b/app/templates/but/parcour_formation.j2 @@ -57,7 +57,7 @@ {% endfor %}