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
+ .