- Amélioration enregistrement note.

- Nouveau point API: /evaluation/<int:evaluation_id>/notes/set
- Corrige API /evaluation/<int:evaluation_id>/notes
- Modernisation de code.
- Améliore tests unitaires APi evaluation.
This commit is contained in:
Emmanuel Viennet 2023-06-03 22:43:04 +02:00
parent bfa8cf1683
commit fb4cabee3b
11 changed files with 347 additions and 293 deletions

View File

@ -8,7 +8,7 @@
ScoDoc 9 API : accès aux évaluations ScoDoc 9 API : accès aux évaluations
""" """
from flask import g from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import login_required from flask_login import login_required
@ -17,7 +17,7 @@ import app
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.models import Evaluation, ModuleImpl, FormSemestre from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db, sco_saisie_notes
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def the_eval(evaluation_id: int): def evaluation(evaluation_id: int):
"""Description d'une évaluation. """Description d'une évaluation.
{ {
@ -93,24 +93,22 @@ def evaluations(moduleimpl_id: int):
@as_json @as_json
def evaluation_notes(evaluation_id: int): def evaluation_notes(evaluation_id: int):
""" """
Retourne la liste des notes à partir de l'id d'une évaluation donnée Retourne la liste des notes de l'évaluation
evaluation_id : l'id d'une évaluation evaluation_id : l'id de l'évaluation
Exemple de résultat : Exemple de résultat :
{ {
"1": { "11": {
"id": 1, "etudid": 11,
"etudid": 10,
"evaluation_id": 1, "evaluation_id": 1,
"value": 15.0, "value": 15.0,
"comment": "", "comment": "",
"date": "Wed, 20 Apr 2022 06:49:05 GMT", "date": "Wed, 20 Apr 2022 06:49:05 GMT",
"uid": 2 "uid": 2
}, },
"2": { "12": {
"id": 2, "etudid": 12,
"etudid": 1,
"evaluation_id": 1, "evaluation_id": 1,
"value": 12.0, "value": 12.0,
"comment": "", "comment": "",
@ -128,8 +126,8 @@ def evaluation_notes(evaluation_id: int):
.filter_by(dept_id=g.scodoc_dept_id) .filter_by(dept_id=g.scodoc_dept_id)
) )
the_eval = query.first_or_404() evaluation = query.first_or_404()
dept = the_eval.moduleimpl.formsemestre.departement dept = evaluation.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym) app.set_sco_dept(dept.acronym)
notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
@ -137,7 +135,49 @@ def evaluation_notes(evaluation_id: int):
# "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval. # "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval.
note = notes[etudid] note = notes[etudid]
note["value"] = scu.fmt_note(note["value"], keep_numeric=True) note["value"] = scu.fmt_note(note["value"], keep_numeric=True)
note["note_max"] = the_eval.note_max note["note_max"] = evaluation.note_max
del note["id"] del note["id"]
return notes # in JS, keys must be string, not integers
return {str(etudid): note for etudid, note in notes.items()}
@bp.route("/evaluation/<int:evaluation_id>/notes/set", methods=["POST"])
@api_web_bp.route("/evaluation/<int:evaluation_id>/notes/set", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEnsView)
@as_json
def evaluation_set_notes(evaluation_id: int):
"""Écriture de notes dans une évaluation.
The request content type should be "application/json",
and contains:
{
'notes' : [ (etudid, value), ... ],
'comment' : opetional string
}
Result:
- nb_changed: nombre de notes changées
- nb_suppress: nombre de notes effacées
- etudids_with_decision: liste des etudiants dont la note a changé
alors qu'ils ont une décision de jury enregistrée.
"""
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)
data = request.get_json(force=True) # may raise 400 Bad Request
notes = data.get("notes")
if notes is None:
return scu.json_error(404, "no notes")
if not isinstance(notes, list):
return scu.json_error(404, "invalid notes argument (must be a list)")
return sco_saisie_notes.save_notes(
evaluation, notes, comment=data.get("comment", "")
)

View File

@ -252,7 +252,7 @@ def do_evaluation_delete(evaluation_id):
def do_evaluation_get_all_notes( def do_evaluation_get_all_notes(
evaluation_id, table="notes_notes", filter_suppressed=True, by_uid=None evaluation_id, table="notes_notes", filter_suppressed=True, by_uid=None
): ):
"""Toutes les notes pour une evaluation: { etudid : { 'value' : value, 'date' : date ... }} """Toutes les notes pour une évaluation: { etudid : { 'value' : value, 'date' : date ... }}
Attention: inclut aussi les notes des étudiants qui ne sont plus inscrits au module. Attention: inclut aussi les notes des étudiants qui ne sont plus inscrits au module.
""" """
# pas de cache pour (rares) appels via undo_notes ou specifiant un enseignant # pas de cache pour (rares) appels via undo_notes ou specifiant un enseignant

View File

@ -36,16 +36,20 @@ import flask
from flask import g, url_for, request from flask import g, url_for, request
from flask_login import current_user from flask_login import current_user
from app import log
from app.auth.models import User from app.auth.models import User
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import Evaluation, FormSemestre from app.models import (
from app.models import ModuleImpl, NotesNotes, ScolarNews Evaluation,
FormSemestre,
Module,
ModuleImpl,
NotesNotes,
ScolarNews,
)
from app.models.etudiants import Identite from app.models.etudiants import Identite
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
import app.scodoc.notesdb as ndb
from app import log
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
InvalidNoteValue, InvalidNoteValue,
@ -55,14 +59,14 @@ from app.scodoc.sco_exceptions import (
ScoInvalidParamError, ScoInvalidParamError,
ScoValueError, ScoValueError,
) )
from app.scodoc.TrivialFormulator import TrivialFormulator, TF
from app.scodoc import html_sco_header, sco_users from app.scodoc import html_sco_header, sco_users
from app.scodoc import htmlutils from app.scodoc import htmlutils
from app.scodoc import sco_abs from app.scodoc import sco_abs
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_edit_module from app.scodoc import sco_edit_module
from app.scodoc import sco_evaluations from app.scodoc import sco_etud
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db
from app.scodoc import sco_evaluations
from app.scodoc import sco_excel from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups from app.scodoc import sco_groups
@ -70,7 +74,11 @@ from app.scodoc import sco_groups_view
from app.scodoc import sco_moduleimpl from app.scodoc import sco_moduleimpl
from app.scodoc import sco_permissions_check from app.scodoc import sco_permissions_check
from app.scodoc import sco_undo_notes from app.scodoc import sco_undo_notes
from app.scodoc import sco_etud import app.scodoc.notesdb as ndb
from app.scodoc.TrivialFormulator import TrivialFormulator, TF
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import json_error
from app.scodoc.sco_utils import ModuleType
def convert_note_from_string( def convert_note_from_string(
@ -128,29 +136,30 @@ def _displayNote(val):
return val return val
def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict): def _check_notes(notes: list[(int, float)], evaluation: Evaluation):
# XXX typehint : float or str # XXX typehint : float or str
"""notes is a list of tuples (etudid, value) """notes is a list of tuples (etudid, value)
mod is the module (used to ckeck type, for malus) mod is the module (used to ckeck type, for malus)
returns list of valid notes (etudid, float value) returns list of valid notes (etudid, float value)
and 4 lists of etudid: invalids, withoutnotes, absents, tosuppress, existingjury and 4 lists of etudid: etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress
""" """
note_max = evaluation["note_max"] note_max = evaluation.note_max or 0.0
if mod["module_type"] in ( module: Module = evaluation.moduleimpl.module
if module.module_type in (
scu.ModuleType.STANDARD, scu.ModuleType.STANDARD,
scu.ModuleType.RESSOURCE, scu.ModuleType.RESSOURCE,
scu.ModuleType.SAE, scu.ModuleType.SAE,
): ):
note_min = scu.NOTES_MIN note_min = scu.NOTES_MIN
elif mod["module_type"] == ModuleType.MALUS: elif module.module_type == ModuleType.MALUS:
note_min = -20.0 note_min = -20.0
else: else:
raise ValueError("Invalid module type") # bug raise ValueError("Invalid module type") # bug
L = [] # liste (etudid, note) des notes ok (ou absent) valid_notes = [] # liste (etudid, note) des notes ok (ou absent)
invalids = [] # etudid avec notes invalides etudids_invalids = [] # etudid avec notes invalides
withoutnotes = [] # etudid sans notes (champs vides) etudids_without_notes = [] # etudid sans notes (champs vides)
absents = [] # etudid absents etudids_absents = [] # etudid absents
tosuppress = [] # etudids avec ancienne note à supprimer etudid_to_suppress = [] # etudids avec ancienne note à supprimer
for etudid, note in notes: for etudid, note in notes:
note = str(note).strip().upper() note = str(note).strip().upper()
@ -166,31 +175,34 @@ def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict):
note_max, note_max,
note_min=note_min, note_min=note_min,
etudid=etudid, etudid=etudid,
absents=absents, absents=etudids_absents,
tosuppress=tosuppress, tosuppress=etudid_to_suppress,
invalids=invalids, invalids=etudids_invalids,
) )
if not invalid: if not invalid:
L.append((etudid, value)) valid_notes.append((etudid, value))
else: else:
withoutnotes.append(etudid) etudids_without_notes.append(etudid)
return L, invalids, withoutnotes, absents, tosuppress return (
valid_notes,
etudids_invalids,
etudids_without_notes,
etudids_absents,
etudid_to_suppress,
)
def do_evaluation_upload_xls(): def do_evaluation_upload_xls():
""" """
Soumission d'un fichier XLS (evaluation_id, notefile) Soumission d'un fichier XLS (evaluation_id, notefile)
""" """
authuser = current_user
vals = scu.get_request_args() vals = scu.get_request_args()
evaluation_id = int(vals["evaluation_id"]) evaluation_id = int(vals["evaluation_id"])
comment = vals["comment"] comment = vals["comment"]
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] # Check access (admin, respformation, and responsable_id)
# Check access if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
# (admin, respformation, and responsable_id) raise AccessDenied(f"Modification des notes impossible pour {current_user}")
if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]):
raise AccessDenied("Modification des notes impossible pour %s" % authuser)
# #
diag, lines = sco_excel.excel_file_to_list(vals["notefile"]) diag, lines = sco_excel.excel_file_to_list(vals["notefile"])
try: try:
@ -239,14 +251,16 @@ def do_evaluation_upload_xls():
if etudid: if etudid:
notes.append((etudid, val)) notes.append((etudid, val))
ni += 1 ni += 1
except: except Exception as exc:
diag.append( diag.append(
f"""Erreur: Ligne invalide ! (erreur ligne {ni})<br>{lines[ni]}""" f"""Erreur: Ligne invalide ! (erreur ligne {ni})<br>{lines[ni]}"""
) )
raise InvalidNoteValue() raise InvalidNoteValue() from exc
# -- check values # -- check values
L, invalids, withoutnotes, absents, _ = _check_notes(notes, E, M["module"]) valid_notes, invalids, withoutnotes, absents, _ = _check_notes(
if len(invalids): notes, evaluation
)
if invalids:
diag.append( diag.append(
f"Erreur: la feuille contient {len(invalids)} notes invalides</p>" f"Erreur: la feuille contient {len(invalids)} notes invalides</p>"
) )
@ -258,37 +272,33 @@ def do_evaluation_upload_xls():
diag.append("Notes invalides pour: " + ", ".join(etudsnames)) diag.append("Notes invalides pour: " + ", ".join(etudsnames))
raise InvalidNoteValue() raise InvalidNoteValue()
else: else:
nb_changed, nb_suppress, existing_decisions = notes_add( etudids_changed, nb_suppress, etudids_with_decisions = notes_add(
authuser, evaluation_id, L, comment current_user, evaluation_id, valid_notes, comment
) )
# news # news
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[ module: Module = evaluation.moduleimpl.module
0 status_url = url_for(
]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
mod["moduleimpl_id"] = M["moduleimpl_id"]
mod["url"] = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=mod["moduleimpl_id"], moduleimpl_id=evaluation.moduleimpl_id,
_external=True, _external=True,
) )
ScolarNews.add( ScolarNews.add(
typ=ScolarNews.NEWS_NOTE, typ=ScolarNews.NEWS_NOTE,
obj=M["moduleimpl_id"], obj=evaluation.moduleimpl_id,
text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % mod, text=f"""Chargement notes dans <a href="{status_url}">{
url=mod["url"], module.titre or module.code}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes max_frequency=30 * 60, # 30 minutes
) )
msg = ( msg = f"""<p>{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, {
"<p>%d notes changées (%d sans notes, %d absents, %d note supprimées)</p>" len(absents)} absents, {nb_suppress} note supprimées)
% (nb_changed, len(withoutnotes), len(absents), nb_suppress) </p>"""
) if etudids_with_decisions:
if existing_decisions: msg += """<p class="warning">Important: il y avait déjà des décisions de jury
msg += """<p class="warning">Important: il y avait déjà des décisions de jury enregistrées, qui sont potentiellement à revoir suite à cette modification !</p>""" enregistrées, qui sont peut-être à revoir suite à cette modification !</p>
# msg += '<p>' + str(notes) # debug """
return 1, msg return 1, msg
except InvalidNoteValue: except InvalidNoteValue:
@ -310,14 +320,12 @@ def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -
if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl.id): if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl.id):
raise AccessDenied(f"Modification des notes impossible pour {current_user}") raise AccessDenied(f"Modification des notes impossible pour {current_user}")
# Convert and check value # Convert and check value
L, invalids, _, _, _ = _check_notes( L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation)
[(etud.id, value)], evaluation.to_dict(), evaluation.moduleimpl.module.to_dict()
)
if len(invalids) == 0: if len(invalids) == 0:
nb_changed, _, _ = notes_add( etudids_changed, _, _ = notes_add(
current_user, evaluation.id, L, "Initialisation notes" current_user, evaluation.id, L, "Initialisation notes"
) )
if nb_changed == 1: if len(etudids_changed) == 1:
return True return True
return False # error return False # error
@ -352,9 +360,7 @@ def do_evaluation_set_missing(
if etudid not in notes_db: # pas de note if etudid not in notes_db: # pas de note
notes.append((etudid, value)) notes.append((etudid, value))
# Convert and check values # Convert and check values
L, invalids, _, _, _ = _check_notes( valid_notes, invalids, _, _, _ = _check_notes(notes, evaluation)
notes, evaluation.to_dict(), modimpl.module.to_dict()
)
dest_url = url_for( dest_url = url_for(
"notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id
) )
@ -372,13 +378,13 @@ def do_evaluation_set_missing(
""" """
# Confirm action # Confirm action
if not dialog_confirmed: if not dialog_confirmed:
plural = len(L) > 1 plural = len(valid_notes) > 1
return scu.confirm_dialog( return scu.confirm_dialog(
f"""<h2>Mettre toutes les notes manquantes de l'évaluation f"""<h2>Mettre toutes les notes manquantes de l'évaluation
à la valeur {value} ?</h2> à la valeur {value} ?</h2>
<p>Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC) <p>Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC)
n'a été rentrée seront affectés.</p> n'a été rentrée seront affectés.</p>
<p><b>{len(L)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""} <p><b>{len(valid_notes)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""}
par ce changement de note.</b> par ce changement de note.</b>
</p> </p>
""", """,
@ -392,7 +398,7 @@ def do_evaluation_set_missing(
) )
# ok # ok
comment = "Initialisation notes manquantes" comment = "Initialisation notes manquantes"
nb_changed, _, _ = notes_add(current_user, evaluation_id, L, comment) etudids_changed, _, _ = notes_add(current_user, evaluation_id, valid_notes, comment)
# news # news
url = url_for( url = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
@ -408,7 +414,7 @@ def do_evaluation_set_missing(
) )
return f""" return f"""
{ html_sco_header.sco_header() } { html_sco_header.sco_header() }
<h2>{nb_changed} notes changées</h2> <h2>{len(etudids_changed)} notes changées</h2>
<ul> <ul>
<li><a class="stdlink" href="{dest_url}"> <li><a class="stdlink" href="{dest_url}">
Revenir au formulaire de saisie des notes</a> Revenir au formulaire de saisie des notes</a>
@ -454,7 +460,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
) )
if not dialog_confirmed: if not dialog_confirmed:
nb_changed, nb_suppress, existing_decisions = notes_add( etudids_changed, nb_suppress, existing_decisions = notes_add(
current_user, evaluation_id, notes, do_it=False, check_inscription=False current_user, evaluation_id, notes, do_it=False, check_inscription=False
) )
msg = f"""<p>Confirmer la suppression des {nb_suppress} notes ? msg = f"""<p>Confirmer la suppression des {nb_suppress} notes ?
@ -475,14 +481,14 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
) )
# modif # modif
nb_changed, nb_suppress, existing_decisions = notes_add( etudids_changed, nb_suppress, existing_decisions = notes_add(
current_user, current_user,
evaluation_id, evaluation_id,
notes, notes,
comment="effacer tout", comment="effacer tout",
check_inscription=False, check_inscription=False,
) )
assert nb_changed == nb_suppress assert len(etudids_changed) == nb_suppress
H = [f"""<p>{nb_suppress} notes supprimées</p>"""] H = [f"""<p>{nb_suppress} notes supprimées</p>"""]
if existing_decisions: if existing_decisions:
H.append( H.append(
@ -516,7 +522,7 @@ def notes_add(
comment=None, comment=None,
do_it=True, do_it=True,
check_inscription=True, check_inscription=True,
) -> tuple: ) -> tuple[list[int], int, list[int]]:
""" """
Insert or update notes Insert or update notes
notes is a list of tuples (etudid,value) notes is a list of tuples (etudid,value)
@ -524,11 +530,12 @@ def notes_add(
WOULD be changed or suppressed. WOULD be changed or suppressed.
Nota: Nota:
- si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log) - si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log)
Return tuple (nb_changed, nb_suppress, existing_decisions)
Return: tuple (etudids_changed, nb_suppress, etudids_with_decision)
""" """
now = psycopg2.Timestamp(*time.localtime()[:6]) now = psycopg2.Timestamp(*time.localtime()[:6])
# Verifie inscription et valeur note # Vérifie inscription et valeur note
inscrits = { inscrits = {
x[0] x[0]
for x in sco_groups.do_evaluation_listeetuds_groups( for x in sco_groups.do_evaluation_listeetuds_groups(
@ -547,13 +554,13 @@ def notes_add(
# Met a jour la base # Met a jour la base
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
nb_changed = 0 etudids_changed = []
nb_suppress = 0 nb_suppress = 0
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id) evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
# etudids pour lesquels il y a une decision de jury et que la note change: # etudids pour lesquels il y a une decision de jury et que la note change:
etudids_with_existing_decision = [] etudids_with_decision = []
try: try:
for etudid, value in notes: for etudid, value in notes:
changed = False changed = False
@ -561,7 +568,7 @@ def notes_add(
# nouvelle note # nouvelle note
if value != scu.NOTES_SUPPRESS: if value != scu.NOTES_SUPPRESS:
if do_it: if do_it:
aa = { args = {
"etudid": etudid, "etudid": etudid,
"evaluation_id": evaluation_id, "evaluation_id": evaluation_id,
"value": value, "value": value,
@ -569,27 +576,21 @@ def notes_add(
"uid": user.id, "uid": user.id,
"date": now, "date": now,
} }
ndb.quote_dict(aa) ndb.quote_dict(args)
try: # Note: le conflit ci-dessous peut arriver si un autre thread
# a modifié la base après qu'on ait lu notes_db
cursor.execute( cursor.execute(
"""INSERT INTO notes_notes """INSERT INTO notes_notes
(etudid, evaluation_id, value, comment, date, uid) (etudid, evaluation_id, value, comment, date, uid)
VALUES (%(etudid)s,%(evaluation_id)s,%(value)s,%(comment)s,%(date)s,%(uid)s) VALUES
(%(etudid)s,%(evaluation_id)s,%(value)s,
%(comment)s,%(date)s,%(uid)s)
ON CONFLICT ON CONSTRAINT notes_notes_etudid_evaluation_id_key
DO UPDATE SET etudid=%(etudid)s, evaluation_id=%(evaluation_id)s,
value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
""", """,
aa, args,
) )
except psycopg2.errors.UniqueViolation as exc:
# XXX ne devrait pas arriver mais bug possible ici (non reproductible)
existing_note = NotesNotes.query.filter_by(
evaluation_id=evaluation_id, etudid=etudid
).first()
sco_cache.EvaluationCache.delete(evaluation_id)
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
evaluation_id
)
raise ScoBugCatcher(
f"dup: existing={existing_note} etudid={repr(etudid)} value={value} in_db={etudid in notes_db}"
) from exc
changed = True changed = True
else: else:
# il y a deja une note # il y a deja une note
@ -615,7 +616,7 @@ def notes_add(
""", """,
{"etudid": etudid, "evaluation_id": evaluation_id}, {"etudid": etudid, "evaluation_id": evaluation_id},
) )
aa = { args = {
"etudid": etudid, "etudid": etudid,
"evaluation_id": evaluation_id, "evaluation_id": evaluation_id,
"value": value, "value": value,
@ -623,7 +624,7 @@ def notes_add(
"comment": comment, "comment": comment,
"uid": user.id, "uid": user.id,
} }
ndb.quote_dict(aa) ndb.quote_dict(args)
if value != scu.NOTES_SUPPRESS: if value != scu.NOTES_SUPPRESS:
if do_it: if do_it:
cursor.execute( cursor.execute(
@ -632,34 +633,36 @@ def notes_add(
WHERE etudid = %(etudid)s WHERE etudid = %(etudid)s
and evaluation_id = %(evaluation_id)s and evaluation_id = %(evaluation_id)s
""", """,
aa, args,
) )
else: # suppression ancienne note else: # suppression ancienne note
if do_it: if do_it:
log( log(
"notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s" f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={
% (evaluation_id, etudid, oldval) etudid}, oldval={oldval}"""
) )
cursor.execute( cursor.execute(
"""DELETE FROM notes_notes """DELETE FROM notes_notes
WHERE etudid = %(etudid)s WHERE etudid = %(etudid)s
AND evaluation_id = %(evaluation_id)s AND evaluation_id = %(evaluation_id)s
""", """,
aa, args,
) )
# garde trace de la suppression dans l'historique: # garde trace de la suppression dans l'historique:
aa["value"] = scu.NOTES_SUPPRESS args["value"] = scu.NOTES_SUPPRESS
cursor.execute( cursor.execute(
"""INSERT INTO notes_notes_log (etudid,evaluation_id,value,comment,date,uid) """INSERT INTO notes_notes_log
VALUES (%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s) (etudid,evaluation_id,value,comment,date,uid)
VALUES
(%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s)
""", """,
aa, args,
) )
nb_suppress += 1 nb_suppress += 1
if changed: if changed:
nb_changed += 1 etudids_changed.append(etudid)
if res.etud_has_decision(etudid): if res.etud_has_decision(etudid):
etudids_with_existing_decision.append(etudid) etudids_with_decision.append(etudid)
except Exception as exc: except Exception as exc:
log("*** exception in notes_add") log("*** exception in notes_add")
if do_it: if do_it:
@ -672,7 +675,7 @@ def notes_add(
cnx.commit() cnx.commit()
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id) sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
sco_cache.EvaluationCache.delete(evaluation_id) sco_cache.EvaluationCache.delete(evaluation_id)
return nb_changed, nb_suppress, etudids_with_existing_decision return etudids_changed, nb_suppress, etudids_with_decision
def saisie_notes_tableur(evaluation_id, group_ids=()): def saisie_notes_tableur(evaluation_id, group_ids=()):
@ -1345,48 +1348,56 @@ def _form_saisie_notes(
return None return None
def save_note(etudid=None, evaluation_id=None, value=None, comment=""): def save_notes(
"""Enregistre une note (ajax)""" evaluation: Evaluation, notes: list[tuple[(int, float)]], comment: str = ""
log( ) -> dict:
f"save_note: evaluation_id={evaluation_id} etudid={etudid} uid={current_user} value={value}" """Enregistre une liste de notes.
) Vérifie que les étudiants sont bien inscrits à ce module, et que l'on a le droit.
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] Result: dict avec
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] """
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] log(f"save_note: evaluation_id={evaluation.id} uid={current_user} notes={notes}")
Mod["url"] = url_for( status_url = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=M["moduleimpl_id"], moduleimpl_id=evaluation.moduleimpl_id,
_external=True, _external=True,
) )
result = {"nbchanged": 0} # JSON
# Check access: admin, respformation, or responsable_id # Check access: admin, respformation, or responsable_id
if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]): if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
result["status"] = "unauthorized" return json_error(403, "modification notes non autorisee pour cet utilisateur")
else: #
L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod) valid_notes, _, _, _, _ = _check_notes(notes, evaluation)
if L: if valid_notes:
nbchanged, _, existing_decisions = notes_add( etudids_changed, _, etudids_with_decision = notes_add(
current_user, evaluation_id, L, comment=comment, do_it=True current_user, evaluation.id, valid_notes, comment=comment, do_it=True
) )
ScolarNews.add( ScolarNews.add(
typ=ScolarNews.NEWS_NOTE, typ=ScolarNews.NEWS_NOTE,
obj=M["moduleimpl_id"], obj=evaluation.moduleimpl_id,
text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % Mod, text=f"""Chargement notes dans <a href="{status_url}">{
url=Mod["url"], evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes max_frequency=30 * 60, # 30 minutes
) )
result["nbchanged"] = nbchanged result = {
result["existing_decisions"] = existing_decisions "etudids_with_decision": etudids_with_decision,
if nbchanged > 0: "etudids_changed": etudids_changed,
result["history_menu"] = get_note_history_menu(evaluation_id, etudid) "history_menu": {
etudid: get_note_history_menu(evaluation.id, etudid)
for etudid in etudids_changed
},
}
else: else:
result["history_menu"] = "" # no update needed result = {
result["status"] = "ok" "etudids_changed": [],
return scu.sendJSON(result) "etudids_with_decision": [],
"history_menu": [],
}
return result
def get_note_history_menu(evaluation_id, etudid): def get_note_history_menu(evaluation_id: int, etudid: int) -> str:
"""Menu HTML historique de la note""" """Menu HTML historique de la note"""
history = sco_undo_notes.get_note_history(evaluation_id, etudid) history = sco_undo_notes.get_note_history(evaluation_id, etudid)
if not history: if not history:

View File

@ -1,35 +1,32 @@
// Formulaire saisie des notes // Formulaire saisie des notes
$().ready(function () { $().ready(function () {
$("#formnotes .note").bind("blur", valid_note); $("#formnotes .note").bind("blur", valid_note);
$("#formnotes input").bind("paste", paste_text); $("#formnotes input").bind("paste", paste_text);
$(".btn_masquer_DEM").bind("click", masquer_DEM); $(".btn_masquer_DEM").bind("click", masquer_DEM);
}); });
function is_valid_note(v) { function is_valid_note(v) {
if (!v) if (!v) return true;
return true;
var note_min = parseFloat($("#eval_note_min").text()); var note_min = parseFloat($("#eval_note_min").text());
var note_max = parseFloat($("#eval_note_max").text()); var note_max = parseFloat($("#eval_note_max").text());
if (!v.match("^-?[0-9]*.?[0-9]*$")) { if (!v.match("^-?[0-9]*.?[0-9]*$")) {
return (v == "ABS") || (v == "EXC") || (v == "SUPR") || (v == "ATT") || (v == "DEM"); return v == "ABS" || v == "EXC" || v == "SUPR" || v == "ATT" || v == "DEM";
} else { } else {
var x = parseFloat(v); var x = parseFloat(v);
return (x >= note_min) && (x <= note_max); return x >= note_min && x <= note_max;
} }
} }
function valid_note(e) { function valid_note(e) {
var v = this.value.trim().toUpperCase().replace(",", "."); var v = this.value.trim().toUpperCase().replace(",", ".");
if (is_valid_note(v)) { if (is_valid_note(v)) {
if (v && (v != $(this).attr('data-last-saved-value'))) { if (v && v != $(this).attr("data-last-saved-value")) {
this.className = "note_valid_new"; this.className = "note_valid_new";
var etudid = $(this).attr('data-etudid'); const etudid = parseInt($(this).attr("data-etudid"));
save_note(this, v, etudid); save_note(this, v, etudid);
} }
} else { } else {
@ -39,66 +36,82 @@ function valid_note(e) {
} }
} }
function save_note(elem, v, etudid) { async function save_note(elem, v, etudid) {
var evaluation_id = $("#formnotes_evaluation_id").attr("value"); let evaluation_id = $("#formnotes_evaluation_id").attr("value");
var formsemestre_id = $("#formnotes_formsemestre_id").attr("value"); let formsemestre_id = $("#formnotes_formsemestre_id").attr("value");
$('#sco_msg').html("en cours...").show(); $("#sco_msg").html("en cours...").show();
$.post(SCO_URL + '/Notes/save_note', try {
const response = await fetch(
SCO_URL + "/../api/evaluation/" + evaluation_id + "/notes/set",
{ {
'etudid': etudid, method: "POST",
'evaluation_id': evaluation_id, headers: {
'value': v, "Content-Type": "application/json",
'comment': document.getElementById('formnotes_comment').value
}, },
function (result) { body: JSON.stringify({
$('#sco_msg').hide(); notes: [[etudid, v]],
if (result['nbchanged'] > 0) { comment: document.getElementById("formnotes_comment").value,
sco_message("enregistré"); }),
elem.className = "note_saved";
// il y avait une decision de jury ?
if (result.existing_decisions[0] == etudid) {
if (v != $(elem).attr('data-orig-value')) {
$("#jurylink_" + etudid).html('<a href="formsemestre_validation_etud_form?formsemestre_id=' + formsemestre_id + '&etudid=' + etudid + '">mettre à jour décision de jury</a>');
} else {
$("#jurylink_" + etudid).html('');
}
}
// mise a jour menu historique
if (result['history_menu']) {
$("#hist_" + etudid).html(result['history_menu']);
}
$(elem).attr('data-last-saved-value', v);
} else {
$('#sco_msg').html("").show();
sco_message("valeur non enregistrée");
}
} }
); );
if (!response.ok) {
sco_message("Erreur: valeur non enregistrée");
} else {
const data = await response.json();
$("#sco_msg").hide();
if (data.etudids_changed.length > 0) {
sco_message("enregistré");
elem.className = "note_saved";
// Il y avait une decision de jury ?
if (data.etudids_with_decision.includes(etudid)) {
if (v != $(elem).attr("data-orig-value")) {
$("#jurylink_" + etudid).html(
'<a href="formsemestre_validation_etud_form?formsemestre_id=' +
formsemestre_id +
"&etudid=" +
etudid +
'">mettre à jour décision de jury</a>'
);
} else {
$("#jurylink_" + etudid).html("");
}
}
// Mise à jour menu historique
if (data.history_menu[etudid]) {
$("#hist_" + etudid).html(data.history_menu[etudid]);
}
$(elem).attr("data-last-saved-value", v);
}
}
} catch (error) {
console.error("Fetch error:", error);
sco_message("Erreur réseau: valeur non enregistrée");
}
} }
function change_history(e) { function change_history(e) {
var opt = e.selectedOptions[0]; let opt = e.selectedOptions[0];
var val = $(opt).attr("data-note"); let val = $(opt).attr("data-note");
var etudid = $(e).attr('data-etudid'); const etudid = parseInt($(e).attr("data-etudid"));
// le input associé a ce menu: // le input associé a ce menu:
var input_elem = e.parentElement.parentElement.parentElement.childNodes[0]; let input_elem = e.parentElement.parentElement.parentElement.childNodes[0];
input_elem.value = val; input_elem.value = val;
save_note(input_elem, val, etudid); save_note(input_elem, val, etudid);
} }
// Contribution S.L.: copier/coller des notes // Contribution S.L.: copier/coller des notes
function paste_text(e) { function paste_text(e) {
var event = e.originalEvent; var event = e.originalEvent;
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
var clipb = e.originalEvent.clipboardData; var clipb = e.originalEvent.clipboardData;
var data = clipb.getData('Text'); var data = clipb.getData("Text");
var list = data.split(/\r\n|\r|\n|\t| /g); var list = data.split(/\r\n|\r|\n|\t| /g);
var currentInput = event.currentTarget; var currentInput = event.currentTarget;
var masquerDEM = document.querySelector("body").classList.contains("masquer_DEM"); var masquerDEM = document
.querySelector("body")
.classList.contains("masquer_DEM");
for (var i = 0; i < list.length; i++) { for (var i = 0; i < list.length; i++) {
currentInput.value = list[i]; currentInput.value = list[i];
@ -108,12 +121,8 @@ function paste_text(e) {
var sibbling = currentInput.parentElement.parentElement.nextElementSibling; var sibbling = currentInput.parentElement.parentElement.nextElementSibling;
while ( while (
sibbling && sibbling &&
( (sibbling.style.display == "none" ||
sibbling.style.display == "none" || (masquerDEM && sibbling.classList.contains("etud_dem")))
(
masquerDEM && sibbling.classList.contains("etud_dem")
)
)
) { ) {
sibbling = sibbling.nextElementSibling; sibbling = sibbling.nextElementSibling;
} }

View File

@ -1875,12 +1875,6 @@ sco_publish(
Permission.ScoEnsView, Permission.ScoEnsView,
) )
sco_publish("/saisie_notes", sco_saisie_notes.saisie_notes, Permission.ScoEnsView) sco_publish("/saisie_notes", sco_saisie_notes.saisie_notes, Permission.ScoEnsView)
sco_publish(
"/save_note",
sco_saisie_notes.save_note,
Permission.ScoEnsView,
methods=["GET", "POST"],
)
sco_publish( sco_publish(
"/do_evaluation_set_missing", "/do_evaluation_set_missing",
sco_saisie_notes.do_evaluation_set_missing, sco_saisie_notes.do_evaluation_set_missing,

View File

@ -1,7 +1,7 @@
# -*- mode: python -*- # -*- mode: python -*-
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
SCOVERSION = "9.4.80" SCOVERSION = "9.4.81"
SCONAME = "ScoDoc" SCONAME = "ScoDoc"

View File

@ -24,7 +24,7 @@ from tests.api.setup_test_api import API_URL, CHECK_CERTIFICATE, api_headers
from tests.api.tools_test_api import ( from tests.api.tools_test_api import (
verify_fields, verify_fields,
EVALUATIONS_FIELDS, EVALUATIONS_FIELDS,
EVALUATION_FIELDS, NOTES_FIELDS,
) )
@ -35,7 +35,7 @@ def test_evaluations(api_headers):
Route : Route :
- /moduleimpl/<int:moduleimpl_id>/evaluations - /moduleimpl/<int:moduleimpl_id>/evaluations
""" """
moduleimpl_id = 1 moduleimpl_id = 20
r = requests.get( r = requests.get(
f"{API_URL}/moduleimpl/{moduleimpl_id}/evaluations", f"{API_URL}/moduleimpl/{moduleimpl_id}/evaluations",
headers=api_headers, headers=api_headers,
@ -44,6 +44,7 @@ def test_evaluations(api_headers):
) )
assert r.status_code == 200 assert r.status_code == 200
list_eval = r.json() list_eval = r.json()
assert list_eval
assert isinstance(list_eval, list) assert isinstance(list_eval, list)
for eval in list_eval: for eval in list_eval:
assert verify_fields(eval, EVALUATIONS_FIELDS) is True assert verify_fields(eval, EVALUATIONS_FIELDS) is True
@ -63,16 +64,14 @@ def test_evaluations(api_headers):
assert eval["moduleimpl_id"] == moduleimpl_id assert eval["moduleimpl_id"] == moduleimpl_id
def test_evaluation_notes( def test_evaluation_notes(api_headers):
api_headers,
): # XXX TODO changer la boucle pour parcourir le dict sans les indices
""" """
Test 'evaluation_notes' Test 'evaluation_notes'
Route : Route :
- /evaluation/<int:evaluation_id>/notes - /evaluation/<int:evaluation_id>/notes
""" """
eval_id = 1 eval_id = 20
r = requests.get( r = requests.get(
f"{API_URL}/evaluation/{eval_id}/notes", f"{API_URL}/evaluation/{eval_id}/notes",
headers=api_headers, headers=api_headers,
@ -81,14 +80,15 @@ def test_evaluation_notes(
) )
assert r.status_code == 200 assert r.status_code == 200
eval_notes = r.json() eval_notes = r.json()
for i in range(1, len(eval_notes)): assert eval_notes
assert verify_fields(eval_notes[f"{i}"], EVALUATION_FIELDS) for etudid, note in eval_notes.items():
assert isinstance(eval_notes[f"{i}"]["id"], int) assert int(etudid) == note["etudid"]
assert isinstance(eval_notes[f"{i}"]["etudid"], int) assert verify_fields(note, NOTES_FIELDS)
assert isinstance(eval_notes[f"{i}"]["evaluation_id"], int) assert isinstance(note["etudid"], int)
assert isinstance(eval_notes[f"{i}"]["value"], float) assert isinstance(note["evaluation_id"], int)
assert isinstance(eval_notes[f"{i}"]["comment"], str) assert isinstance(note["value"], float)
assert isinstance(eval_notes[f"{i}"]["date"], str) assert isinstance(note["comment"], str)
assert isinstance(eval_notes[f"{i}"]["uid"], int) assert isinstance(note["date"], str)
assert isinstance(note["uid"], int)
assert eval_id == eval_notes[f"{i}"]["evaluation_id"] assert eval_id == note["evaluation_id"]

View File

@ -58,6 +58,7 @@ def test_permissions(api_headers):
"nip": 1, "nip": 1,
"partition_id": 1, "partition_id": 1,
"role_name": "Ens", "role_name": "Ens",
"start": "abc",
"uid": 1, "uid": 1,
"version": "long", "version": "long",
} }

View File

@ -568,8 +568,7 @@ EVALUATIONS_FIELDS = {
"visi_bulletin", "visi_bulletin",
} }
EVALUATION_FIELDS = { NOTES_FIELDS = {
"id",
"etudid", "etudid",
"evaluation_id", "evaluation_id",
"value", "value",

View File

@ -103,14 +103,14 @@ def run_sco_basic(verbose=False) -> FormSemestre:
# --- Saisie toutes les notes de l'évaluation # --- Saisie toutes les notes de l'évaluation
for idx, etud in enumerate(etuds): for idx, etud in enumerate(etuds):
nb_changed, nb_suppress, existing_decisions = G.create_note( etudids_changed, nb_suppress, existing_decisions = G.create_note(
evaluation_id=e["id"], evaluation_id=e["id"],
etudid=etud["etudid"], etudid=etud["etudid"],
note=NOTES_T[idx % len(NOTES_T)], note=NOTES_T[idx % len(NOTES_T)],
) )
assert not existing_decisions assert not existing_decisions
assert nb_suppress == 0 assert nb_suppress == 0
assert nb_changed == 1 assert len(etudids_changed) == 1
# --- Vérifie que les notes sont prises en compte: # --- Vérifie que les notes sont prises en compte:
b = sco_bulletins.formsemestre_bulletinetud_dict(formsemestre_id, etud["etudid"]) b = sco_bulletins.formsemestre_bulletinetud_dict(formsemestre_id, etud["etudid"])
@ -136,7 +136,7 @@ def run_sco_basic(verbose=False) -> FormSemestre:
) )
# Saisie les notes des 5 premiers étudiants: # Saisie les notes des 5 premiers étudiants:
for idx, etud in enumerate(etuds[:5]): for idx, etud in enumerate(etuds[:5]):
nb_changed, nb_suppress, existing_decisions = G.create_note( etudids_changed, nb_suppress, existing_decisions = G.create_note(
evaluation_id=e2["id"], evaluation_id=e2["id"],
etudid=etud["etudid"], etudid=etud["etudid"],
note=NOTES_T[idx % len(NOTES_T)], note=NOTES_T[idx % len(NOTES_T)],
@ -158,7 +158,7 @@ def run_sco_basic(verbose=False) -> FormSemestre:
# Saisie des notes qui manquent: # Saisie des notes qui manquent:
for idx, etud in enumerate(etuds[5:]): for idx, etud in enumerate(etuds[5:]):
nb_changed, nb_suppress, existing_decisions = G.create_note( etudids_changed, nb_suppress, existing_decisions = G.create_note(
evaluation_id=e2["id"], evaluation_id=e2["id"],
etudid=etud["etudid"], etudid=etud["etudid"],
note=NOTES_T[idx % len(NOTES_T)], note=NOTES_T[idx % len(NOTES_T)],

View File

@ -239,7 +239,7 @@ def create_evaluations(formsemestre: FormSemestre):
"jour": datetime.date(2022, 3, 1) + datetime.timedelta(days=modimpl.id), "jour": datetime.date(2022, 3, 1) + datetime.timedelta(days=modimpl.id),
"heure_debut": "8h00", "heure_debut": "8h00",
"heure_fin": "9h00", "heure_fin": "9h00",
"description": None, "description": f"Evaluation-{modimpl.module.code}",
"note_max": 20, "note_max": 20,
"coefficient": 1.0, "coefficient": 1.0,
"visibulletin": True, "visibulletin": True,