API evaluation: create avec poids, /delete + tests unitaires + corrections

This commit is contained in:
Emmanuel Viennet 2023-08-26 16:34:56 +02:00
parent 94f857665c
commit 1d3726a4cd
17 changed files with 304 additions and 124 deletions

View File

@ -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

View File

@ -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,

View File

@ -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"

View File

@ -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/<int:moduleimpl_id>")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>")
@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/<int:ue_id>", methods=["POST"])
@api_web_bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@login_required

View File

@ -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

69
app/api/moduleimpl.py Normal file
View File

@ -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/<int:moduleimpl_id>")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>")
@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)

View File

@ -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):

View File

@ -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()

View File

@ -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 <a href="{url}">{
moduleimpl.module.titre or '(module sans titre)'}</a>""",
moduleimpl.module.titre_str()}</a>""",
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)

View File

@ -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

View File

@ -57,8 +57,10 @@ def _pe_view_sem_recap_form(formsemestre_id):
poursuites d'études.
<br>
De nombreux aspects sont paramétrables:
<a href="https://scodoc.org/AvisPoursuiteEtudes" target="_blank" rel="noopener">
voir la documentation</a>.
<a href="https://scodoc.org/AvisPoursuiteEtudes"
target="_blank" rel="noopener noreferrer">
voir la documentation
</a>.
</p>
<form method="post" action="pe_view_sem_recap" id="pe_view_sem_recap_form"
enctype="multipart/form-data">

View File

@ -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:

View File

@ -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()

View File

@ -502,6 +502,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
"""
]
# news
if nb_suppress:
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=evaluation.moduleimpl.id,

View File

@ -57,7 +57,7 @@
</div>
{% endfor %}
<div class="link">
<a class="stdlink" target="_blank" href="{{
<a class="stdlink" target="_blank" rel="noopener noreferrer" href="{{
url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id )
}}">référentiel de compétences</a>

View File

@ -108,6 +108,12 @@ def test_evaluation_create(api_admin_headers):
Test /moduleimpl/<int:moduleimpl_id>/evaluation/create
"""
moduleimpl_id = 20
# Nombre d'évaluations initial
evaluations = GET(
f"/moduleimpl/{moduleimpl_id}/evaluations", headers=api_admin_headers
)
nb_evals = len(evaluations)
#
e = POST_JSON(
f"/moduleimpl/{moduleimpl_id}/evaluation/create",
{"description": "eval test"},
@ -123,6 +129,11 @@ def test_evaluation_create(api_admin_headers):
assert e["visibulletin"] is True
assert e["publish_incomplete"] is False
assert e["coefficient"] == 1.0
new_nb_evals = len(
GET(f"/moduleimpl/{moduleimpl_id}/evaluations", headers=api_admin_headers)
)
assert new_nb_evals == nb_evals + 1
nb_evals = new_nb_evals
# Avec une erreur
check_failure_post(
@ -131,7 +142,10 @@ def test_evaluation_create(api_admin_headers):
{"evaluation_type": 666},
err="paramètre de type incorrect (invalid evaluation_type value)",
)
new_nb_evals = len(
GET(f"/moduleimpl/{moduleimpl_id}/evaluations", headers=api_admin_headers)
)
assert new_nb_evals == nb_evals # inchangé
# Avec plein de valeurs
data = {
"coefficient": 12.0,
@ -148,11 +162,72 @@ def test_evaluation_create(api_admin_headers):
data,
api_admin_headers,
)
e2 = GET(f"/evaluation/{e['id']}", headers=api_admin_headers)
e_ret = GET(f"/evaluation/{e['id']}", headers=api_admin_headers)
for k, v in data.items():
assert e2[k] == v, f"received '{e2[k]}'"
assert e_ret[k] == v, f"received '{e_ret[k]}'"
# TODO
# - tester creation UE externe
# - tester création base test et test API
# Avec des poids APC
nb_evals = len(
GET(f"/moduleimpl/{moduleimpl_id}/evaluations", headers=api_admin_headers)
)
data.update(
{
"description": "eval test apc erreur",
"poids": {"666": 666.0}, # poids erroné: UE inexistante
}
)
check_failure_post(
f"/moduleimpl/{moduleimpl_id}/evaluation/create",
api_admin_headers,
data,
)
new_nb_evals = len(
GET(f"/moduleimpl/{moduleimpl_id}/evaluations", headers=api_admin_headers)
)
assert new_nb_evals == nb_evals # inchangé
# Avec des poids absurdes
data.update({"description": "eval test apc erreur 2", "poids": "nimporte quoi"})
check_failure_post(
f"/moduleimpl/{moduleimpl_id}/evaluation/create",
api_admin_headers,
data,
)
new_nb_evals = len(
GET(f"/moduleimpl/{moduleimpl_id}/evaluations", headers=api_admin_headers)
)
assert new_nb_evals == nb_evals # inchangé
# Avec de bons poids
# pour cela il nous faut les UEs de ce formsemestre
# sachant que l'on a moduleimpl
modimpl = GET(f"/moduleimpl/{moduleimpl_id}", headers=api_admin_headers)
formation = GET(
f"/formsemestre/{modimpl['formsemestre_id']}/programme",
headers=api_admin_headers,
)
ues = formation["ues"]
assert len(ues)
ue_ids = [ue["id"] for ue in ues]
poids = {ue_id: float(i) + 0.5 for i, ue_id in enumerate(ue_ids)}
data.update({"description": "eval avec poids", "poids": poids})
e = POST_JSON(
f"/moduleimpl/{moduleimpl_id}/evaluation/create",
data,
api_admin_headers,
)
assert e["poids"]
e_ret = GET(f"/evaluation/{e['id']}", headers=api_admin_headers)
assert e_ret["poids"] == e["poids"]
new_nb_evals = len(
GET(f"/moduleimpl/{moduleimpl_id}/evaluations", headers=api_admin_headers)
)
assert new_nb_evals == nb_evals + 1
nb_evals = new_nb_evals
# Delete
ans = POST_JSON(
f"/evaluation/{e_ret['id']}/delete",
headers=api_admin_headers,
)
assert ans == "ok"
assert nb_evals - 1 == len(
GET(f"/moduleimpl/{moduleimpl_id}/evaluations", headers=api_admin_headers)
)

View File

@ -4,6 +4,21 @@
# Ce script lance un serveur scodoc sur le port 5555
# attend qu'il soit initialisé puis lance les tests client API.
#
# On peut aussi le lancer avec l'option --dont-start-server
# auquel cas il utilise un serveur existant, qui doit avoir été lancé
# par ailleurs, par exemple via le script:
# tests/api/start_api_server.sh -p 5555
#
# Toutes les autres options sont passées telles qu'elles à pytest
#
# Exemples:
# - lancer tous les tests API: tools/test_api.sh
# - lancer tous les tests, en mode debug (arrêt pdb sur le 1er):
# tools/test_api.sh -x --pdb tests/api
# - lancer un module de test, en utilisant un server dev existant:
# tools/test_api.sh --dont-start-server -x --pdb tests/api/test_api_evaluations.py
#
#
# E. Viennet, Fev 2023
cd /opt/scodoc
@ -16,6 +31,17 @@ SERVER_LOG=/tmp/test_api_server.log
export SCODOC_URL="http://localhost:${PORT}"
if [ "$1" = "--dont-start-server" ]
then
START_SERVER=0
shift
echo "Using existing scodoc server on port $PORT"
else
START_SERVER=1
fi
if [ "$START_SERVER" -eq 1 ]
then
# ------- Check pas de serveur déjà lancé
if nc -z localhost "$PORT"
then
@ -38,6 +64,7 @@ done
echo
echo Server PID "$pid" running on port "$PORT"
# ------------------
fi
if [ "$#" -eq 0 ]
then
@ -49,9 +76,12 @@ else
fi
# ------------------
echo "Killing server"
if [ "$START_SERVER" -eq 1 ]
then
echo "Killing test server"
kill "$pid"
fuser -k "$PORT"/tcp
fi