1
0
forked from ScoDoc/ScoDoc

- 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 committed by iziram
parent 4b4e52bf2d
commit 9d18ed4671
11 changed files with 347 additions and 293 deletions

View File

@ -8,7 +8,7 @@
ScoDoc 9 API : accès aux évaluations
"""
from flask import g
from flask import g, request
from flask_json import as_json
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.decorators import scodoc, permission_required
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
import app.scodoc.sco_utils as scu
@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu
@scodoc
@permission_required(Permission.ScoView)
@as_json
def the_eval(evaluation_id: int):
def evaluation(evaluation_id: int):
"""Description d'une évaluation.
{
@ -93,24 +93,22 @@ def evaluations(moduleimpl_id: int):
@as_json
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 :
{
"1": {
"id": 1,
"etudid": 10,
"11": {
"etudid": 11,
"evaluation_id": 1,
"value": 15.0,
"comment": "",
"date": "Wed, 20 Apr 2022 06:49:05 GMT",
"uid": 2
},
"2": {
"id": 2,
"etudid": 1,
"12": {
"etudid": 12,
"evaluation_id": 1,
"value": 12.0,
"comment": "",
@ -128,8 +126,8 @@ def evaluation_notes(evaluation_id: int):
.filter_by(dept_id=g.scodoc_dept_id)
)
the_eval = query.first_or_404()
dept = the_eval.moduleimpl.formsemestre.departement
evaluation = query.first_or_404()
dept = evaluation.moduleimpl.formsemestre.departement
app.set_sco_dept(dept.acronym)
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.
note = notes[etudid]
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"]
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(
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.
"""
# 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_login import current_user
from app import log
from app.auth.models import User
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Evaluation, FormSemestre
from app.models import ModuleImpl, NotesNotes, ScolarNews
from app.models import (
Evaluation,
FormSemestre,
Module,
ModuleImpl,
NotesNotes,
ScolarNews,
)
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 (
AccessDenied,
InvalidNoteValue,
@ -55,14 +59,14 @@ from app.scodoc.sco_exceptions import (
ScoInvalidParamError,
ScoValueError,
)
from app.scodoc.TrivialFormulator import TrivialFormulator, TF
from app.scodoc import html_sco_header, sco_users
from app.scodoc import htmlutils
from app.scodoc import sco_abs
from app.scodoc import sco_cache
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_evaluations
from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre_inscriptions
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_permissions_check
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(
@ -128,29 +136,30 @@ def _displayNote(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
"""notes is a list of tuples (etudid, value)
mod is the module (used to ckeck type, for malus)
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"]
if mod["module_type"] in (
note_max = evaluation.note_max or 0.0
module: Module = evaluation.moduleimpl.module
if module.module_type in (
scu.ModuleType.STANDARD,
scu.ModuleType.RESSOURCE,
scu.ModuleType.SAE,
):
note_min = scu.NOTES_MIN
elif mod["module_type"] == ModuleType.MALUS:
elif module.module_type == ModuleType.MALUS:
note_min = -20.0
else:
raise ValueError("Invalid module type") # bug
L = [] # liste (etudid, note) des notes ok (ou absent)
invalids = [] # etudid avec notes invalides
withoutnotes = [] # etudid sans notes (champs vides)
absents = [] # etudid absents
tosuppress = [] # etudids avec ancienne note à supprimer
valid_notes = [] # liste (etudid, note) des notes ok (ou absent)
etudids_invalids = [] # etudid avec notes invalides
etudids_without_notes = [] # etudid sans notes (champs vides)
etudids_absents = [] # etudid absents
etudid_to_suppress = [] # etudids avec ancienne note à supprimer
for etudid, note in notes:
note = str(note).strip().upper()
@ -166,31 +175,34 @@ def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict):
note_max,
note_min=note_min,
etudid=etudid,
absents=absents,
tosuppress=tosuppress,
invalids=invalids,
absents=etudids_absents,
tosuppress=etudid_to_suppress,
invalids=etudids_invalids,
)
if not invalid:
L.append((etudid, value))
valid_notes.append((etudid, value))
else:
withoutnotes.append(etudid)
return L, invalids, withoutnotes, absents, tosuppress
etudids_without_notes.append(etudid)
return (
valid_notes,
etudids_invalids,
etudids_without_notes,
etudids_absents,
etudid_to_suppress,
)
def do_evaluation_upload_xls():
"""
Soumission d'un fichier XLS (evaluation_id, notefile)
"""
authuser = current_user
vals = scu.get_request_args()
evaluation_id = int(vals["evaluation_id"])
comment = vals["comment"]
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0]
# Check access
# (admin, respformation, and responsable_id)
if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]):
raise AccessDenied("Modification des notes impossible pour %s" % authuser)
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
# Check access (admin, respformation, and responsable_id)
if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
raise AccessDenied(f"Modification des notes impossible pour {current_user}")
#
diag, lines = sco_excel.excel_file_to_list(vals["notefile"])
try:
@ -239,14 +251,16 @@ def do_evaluation_upload_xls():
if etudid:
notes.append((etudid, val))
ni += 1
except:
except Exception as exc:
diag.append(
f"""Erreur: Ligne invalide ! (erreur ligne {ni})<br>{lines[ni]}"""
)
raise InvalidNoteValue()
raise InvalidNoteValue() from exc
# -- check values
L, invalids, withoutnotes, absents, _ = _check_notes(notes, E, M["module"])
if len(invalids):
valid_notes, invalids, withoutnotes, absents, _ = _check_notes(
notes, evaluation
)
if invalids:
diag.append(
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))
raise InvalidNoteValue()
else:
nb_changed, nb_suppress, existing_decisions = notes_add(
authuser, evaluation_id, L, comment
etudids_changed, nb_suppress, etudids_with_decisions = notes_add(
current_user, evaluation_id, valid_notes, comment
)
# news
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[
0
]
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(
module: Module = evaluation.moduleimpl.module
status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=mod["moduleimpl_id"],
moduleimpl_id=evaluation.moduleimpl_id,
_external=True,
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=M["moduleimpl_id"],
text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % mod,
url=mod["url"],
obj=evaluation.moduleimpl_id,
text=f"""Chargement notes dans <a href="{status_url}">{
module.titre or module.code}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes
)
msg = (
"<p>%d notes changées (%d sans notes, %d absents, %d note supprimées)</p>"
% (nb_changed, len(withoutnotes), len(absents), nb_suppress)
)
if existing_decisions:
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>"""
# msg += '<p>' + str(notes) # debug
msg = f"""<p>{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, {
len(absents)} absents, {nb_suppress} note supprimées)
</p>"""
if etudids_with_decisions:
msg += """<p class="warning">Important: il y avait déjà des décisions de jury
enregistrées, qui sont peut-être à revoir suite à cette modification !</p>
"""
return 1, msg
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):
raise AccessDenied(f"Modification des notes impossible pour {current_user}")
# Convert and check value
L, invalids, _, _, _ = _check_notes(
[(etud.id, value)], evaluation.to_dict(), evaluation.moduleimpl.module.to_dict()
)
L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation)
if len(invalids) == 0:
nb_changed, _, _ = notes_add(
etudids_changed, _, _ = notes_add(
current_user, evaluation.id, L, "Initialisation notes"
)
if nb_changed == 1:
if len(etudids_changed) == 1:
return True
return False # error
@ -352,9 +360,7 @@ def do_evaluation_set_missing(
if etudid not in notes_db: # pas de note
notes.append((etudid, value))
# Convert and check values
L, invalids, _, _, _ = _check_notes(
notes, evaluation.to_dict(), modimpl.module.to_dict()
)
valid_notes, invalids, _, _, _ = _check_notes(notes, evaluation)
dest_url = url_for(
"notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id
)
@ -372,13 +378,13 @@ def do_evaluation_set_missing(
"""
# Confirm action
if not dialog_confirmed:
plural = len(L) > 1
plural = len(valid_notes) > 1
return scu.confirm_dialog(
f"""<h2>Mettre toutes les notes manquantes de l'évaluation
à la valeur {value} ?</h2>
<p>Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC)
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>
</p>
""",
@ -392,7 +398,7 @@ def do_evaluation_set_missing(
)
# ok
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
url = url_for(
"notes.moduleimpl_status",
@ -408,7 +414,7 @@ def do_evaluation_set_missing(
)
return f"""
{ html_sco_header.sco_header() }
<h2>{nb_changed} notes changées</h2>
<h2>{len(etudids_changed)} notes changées</h2>
<ul>
<li><a class="stdlink" href="{dest_url}">
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:
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
)
msg = f"""<p>Confirmer la suppression des {nb_suppress} notes ?
@ -475,14 +481,14 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
)
# modif
nb_changed, nb_suppress, existing_decisions = notes_add(
etudids_changed, nb_suppress, existing_decisions = notes_add(
current_user,
evaluation_id,
notes,
comment="effacer tout",
check_inscription=False,
)
assert nb_changed == nb_suppress
assert len(etudids_changed) == nb_suppress
H = [f"""<p>{nb_suppress} notes supprimées</p>"""]
if existing_decisions:
H.append(
@ -516,7 +522,7 @@ def notes_add(
comment=None,
do_it=True,
check_inscription=True,
) -> tuple:
) -> tuple[list[int], int, list[int]]:
"""
Insert or update notes
notes is a list of tuples (etudid,value)
@ -524,11 +530,12 @@ def notes_add(
WOULD be changed or suppressed.
Nota:
- 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])
# Verifie inscription et valeur note
# Vérifie inscription et valeur note
inscrits = {
x[0]
for x in sco_groups.do_evaluation_listeetuds_groups(
@ -547,13 +554,13 @@ def notes_add(
# Met a jour la base
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
nb_changed = 0
etudids_changed = []
nb_suppress = 0
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
formsemestre: FormSemestre = evaluation.moduleimpl.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_with_existing_decision = []
etudids_with_decision = []
try:
for etudid, value in notes:
changed = False
@ -561,7 +568,7 @@ def notes_add(
# nouvelle note
if value != scu.NOTES_SUPPRESS:
if do_it:
aa = {
args = {
"etudid": etudid,
"evaluation_id": evaluation_id,
"value": value,
@ -569,27 +576,21 @@ def notes_add(
"uid": user.id,
"date": now,
}
ndb.quote_dict(aa)
try:
cursor.execute(
"""INSERT INTO notes_notes
(etudid, evaluation_id, value, comment, date, uid)
VALUES (%(etudid)s,%(evaluation_id)s,%(value)s,%(comment)s,%(date)s,%(uid)s)
""",
aa,
)
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
ndb.quote_dict(args)
# 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(
"""INSERT INTO notes_notes
(etudid, evaluation_id, value, comment, date, uid)
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
""",
args,
)
changed = True
else:
# il y a deja une note
@ -615,7 +616,7 @@ def notes_add(
""",
{"etudid": etudid, "evaluation_id": evaluation_id},
)
aa = {
args = {
"etudid": etudid,
"evaluation_id": evaluation_id,
"value": value,
@ -623,7 +624,7 @@ def notes_add(
"comment": comment,
"uid": user.id,
}
ndb.quote_dict(aa)
ndb.quote_dict(args)
if value != scu.NOTES_SUPPRESS:
if do_it:
cursor.execute(
@ -632,34 +633,36 @@ def notes_add(
WHERE etudid = %(etudid)s
and evaluation_id = %(evaluation_id)s
""",
aa,
args,
)
else: # suppression ancienne note
if do_it:
log(
"notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s"
% (evaluation_id, etudid, oldval)
f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={
etudid}, oldval={oldval}"""
)
cursor.execute(
"""DELETE FROM notes_notes
WHERE etudid = %(etudid)s
AND evaluation_id = %(evaluation_id)s
""",
aa,
args,
)
# garde trace de la suppression dans l'historique:
aa["value"] = scu.NOTES_SUPPRESS
args["value"] = scu.NOTES_SUPPRESS
cursor.execute(
"""INSERT INTO notes_notes_log (etudid,evaluation_id,value,comment,date,uid)
VALUES (%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s)
"""INSERT INTO notes_notes_log
(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
if changed:
nb_changed += 1
etudids_changed.append(etudid)
if res.etud_has_decision(etudid):
etudids_with_existing_decision.append(etudid)
etudids_with_decision.append(etudid)
except Exception as exc:
log("*** exception in notes_add")
if do_it:
@ -672,7 +675,7 @@ def notes_add(
cnx.commit()
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.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=()):
@ -1345,48 +1348,56 @@ def _form_saisie_notes(
return None
def save_note(etudid=None, evaluation_id=None, value=None, comment=""):
"""Enregistre une note (ajax)"""
log(
f"save_note: evaluation_id={evaluation_id} etudid={etudid} uid={current_user} value={value}"
)
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
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["url"] = url_for(
def save_notes(
evaluation: Evaluation, notes: list[tuple[(int, float)]], comment: str = ""
) -> dict:
"""Enregistre une liste de notes.
Vérifie que les étudiants sont bien inscrits à ce module, et que l'on a le droit.
Result: dict avec
"""
log(f"save_note: evaluation_id={evaluation.id} uid={current_user} notes={notes}")
status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=M["moduleimpl_id"],
moduleimpl_id=evaluation.moduleimpl_id,
_external=True,
)
result = {"nbchanged": 0} # JSON
# Check access: admin, respformation, or responsable_id
if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]):
result["status"] = "unauthorized"
if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id):
return json_error(403, "modification notes non autorisee pour cet utilisateur")
#
valid_notes, _, _, _, _ = _check_notes(notes, evaluation)
if valid_notes:
etudids_changed, _, etudids_with_decision = notes_add(
current_user, evaluation.id, valid_notes, comment=comment, do_it=True
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=evaluation.moduleimpl_id,
text=f"""Chargement notes dans <a href="{status_url}">{
evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes
)
result = {
"etudids_with_decision": etudids_with_decision,
"etudids_changed": etudids_changed,
"history_menu": {
etudid: get_note_history_menu(evaluation.id, etudid)
for etudid in etudids_changed
},
}
else:
L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod)
if L:
nbchanged, _, existing_decisions = notes_add(
current_user, evaluation_id, L, comment=comment, do_it=True
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=M["moduleimpl_id"],
text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % Mod,
url=Mod["url"],
max_frequency=30 * 60, # 30 minutes
)
result["nbchanged"] = nbchanged
result["existing_decisions"] = existing_decisions
if nbchanged > 0:
result["history_menu"] = get_note_history_menu(evaluation_id, etudid)
else:
result["history_menu"] = "" # no update needed
result["status"] = "ok"
return scu.sendJSON(result)
result = {
"etudids_changed": [],
"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"""
history = sco_undo_notes.get_note_history(evaluation_id, etudid)
if not history:

View File

@ -1,133 +1,142 @@
// Formulaire saisie des notes
$().ready(function () {
$("#formnotes .note").bind("blur", valid_note);
$("#formnotes .note").bind("blur", valid_note);
$("#formnotes input").bind("paste", paste_text);
$(".btn_masquer_DEM").bind("click", masquer_DEM);
$("#formnotes input").bind("paste", paste_text);
$(".btn_masquer_DEM").bind("click", masquer_DEM);
});
function is_valid_note(v) {
if (!v)
return true;
if (!v) return true;
var note_min = parseFloat($("#eval_note_min").text());
var note_max = parseFloat($("#eval_note_max").text());
var note_min = parseFloat($("#eval_note_min").text());
var note_max = parseFloat($("#eval_note_max").text());
if (!v.match("^-?[0-9]*.?[0-9]*$")) {
return (v == "ABS") || (v == "EXC") || (v == "SUPR") || (v == "ATT") || (v == "DEM");
} else {
var x = parseFloat(v);
return (x >= note_min) && (x <= note_max);
}
if (!v.match("^-?[0-9]*.?[0-9]*$")) {
return v == "ABS" || v == "EXC" || v == "SUPR" || v == "ATT" || v == "DEM";
} else {
var x = parseFloat(v);
return x >= note_min && x <= note_max;
}
}
function valid_note(e) {
var v = this.value.trim().toUpperCase().replace(",", ".");
if (is_valid_note(v)) {
if (v && (v != $(this).attr('data-last-saved-value'))) {
this.className = "note_valid_new";
var etudid = $(this).attr('data-etudid');
save_note(this, v, etudid);
}
} else {
/* Saisie invalide */
this.className = "note_invalid";
sco_message("valeur invalide ou hors barème");
var v = this.value.trim().toUpperCase().replace(",", ".");
if (is_valid_note(v)) {
if (v && v != $(this).attr("data-last-saved-value")) {
this.className = "note_valid_new";
const etudid = parseInt($(this).attr("data-etudid"));
save_note(this, v, etudid);
}
} else {
/* Saisie invalide */
this.className = "note_invalid";
sco_message("valeur invalide ou hors barème");
}
}
function save_note(elem, v, etudid) {
var evaluation_id = $("#formnotes_evaluation_id").attr("value");
var formsemestre_id = $("#formnotes_formsemestre_id").attr("value");
$('#sco_msg').html("en cours...").show();
$.post(SCO_URL + '/Notes/save_note',
{
'etudid': etudid,
'evaluation_id': evaluation_id,
'value': v,
'comment': document.getElementById('formnotes_comment').value
async function save_note(elem, v, etudid) {
let evaluation_id = $("#formnotes_evaluation_id").attr("value");
let formsemestre_id = $("#formnotes_formsemestre_id").attr("value");
$("#sco_msg").html("en cours...").show();
try {
const response = await fetch(
SCO_URL + "/../api/evaluation/" + evaluation_id + "/notes/set",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
function (result) {
$('#sco_msg').hide();
if (result['nbchanged'] > 0) {
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");
}
}
body: JSON.stringify({
notes: [[etudid, v]],
comment: document.getElementById("formnotes_comment").value,
}),
}
);
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) {
var opt = e.selectedOptions[0];
var val = $(opt).attr("data-note");
var etudid = $(e).attr('data-etudid');
// le input associé a ce menu:
var input_elem = e.parentElement.parentElement.parentElement.childNodes[0];
input_elem.value = val;
save_note(input_elem, val, etudid);
let opt = e.selectedOptions[0];
let val = $(opt).attr("data-note");
const etudid = parseInt($(e).attr("data-etudid"));
// le input associé a ce menu:
let input_elem = e.parentElement.parentElement.parentElement.childNodes[0];
input_elem.value = val;
save_note(input_elem, val, etudid);
}
// Contribution S.L.: copier/coller des notes
function paste_text(e) {
var event = e.originalEvent;
event.stopPropagation();
event.preventDefault();
var clipb = e.originalEvent.clipboardData;
var data = clipb.getData('Text');
var list = data.split(/\r\n|\r|\n|\t| /g);
var currentInput = event.currentTarget;
var masquerDEM = document.querySelector("body").classList.contains("masquer_DEM");
var event = e.originalEvent;
event.stopPropagation();
event.preventDefault();
var clipb = e.originalEvent.clipboardData;
var data = clipb.getData("Text");
var list = data.split(/\r\n|\r|\n|\t| /g);
var currentInput = event.currentTarget;
var masquerDEM = document
.querySelector("body")
.classList.contains("masquer_DEM");
for (var i = 0; i < list.length; i++) {
currentInput.value = list[i];
var evt = document.createEvent("HTMLEvents");
evt.initEvent("blur", false, true);
currentInput.dispatchEvent(evt);
var sibbling = currentInput.parentElement.parentElement.nextElementSibling;
while (
sibbling &&
(
sibbling.style.display == "none" ||
(
masquerDEM && sibbling.classList.contains("etud_dem")
)
)
) {
sibbling = sibbling.nextElementSibling;
}
if (sibbling) {
currentInput = sibbling.querySelector("input");
if (!currentInput) {
return;
}
} else {
return;
}
for (var i = 0; i < list.length; i++) {
currentInput.value = list[i];
var evt = document.createEvent("HTMLEvents");
evt.initEvent("blur", false, true);
currentInput.dispatchEvent(evt);
var sibbling = currentInput.parentElement.parentElement.nextElementSibling;
while (
sibbling &&
(sibbling.style.display == "none" ||
(masquerDEM && sibbling.classList.contains("etud_dem")))
) {
sibbling = sibbling.nextElementSibling;
}
if (sibbling) {
currentInput = sibbling.querySelector("input");
if (!currentInput) {
return;
}
} else {
return;
}
}
}
function masquer_DEM() {
document.querySelector("body").classList.toggle("masquer_DEM");
document.querySelector("body").classList.toggle("masquer_DEM");
}

View File

@ -1875,12 +1875,6 @@ sco_publish(
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(
"/do_evaluation_set_missing",
sco_saisie_notes.do_evaluation_set_missing,

View File

@ -1,7 +1,7 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
SCOVERSION = "9.4.80"
SCOVERSION = "9.4.81"
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 (
verify_fields,
EVALUATIONS_FIELDS,
EVALUATION_FIELDS,
NOTES_FIELDS,
)
@ -35,7 +35,7 @@ def test_evaluations(api_headers):
Route :
- /moduleimpl/<int:moduleimpl_id>/evaluations
"""
moduleimpl_id = 1
moduleimpl_id = 20
r = requests.get(
f"{API_URL}/moduleimpl/{moduleimpl_id}/evaluations",
headers=api_headers,
@ -44,6 +44,7 @@ def test_evaluations(api_headers):
)
assert r.status_code == 200
list_eval = r.json()
assert list_eval
assert isinstance(list_eval, list)
for eval in list_eval:
assert verify_fields(eval, EVALUATIONS_FIELDS) is True
@ -63,16 +64,14 @@ def test_evaluations(api_headers):
assert eval["moduleimpl_id"] == moduleimpl_id
def test_evaluation_notes(
api_headers,
): # XXX TODO changer la boucle pour parcourir le dict sans les indices
def test_evaluation_notes(api_headers):
"""
Test 'evaluation_notes'
Route :
- /evaluation/<int:evaluation_id>/notes
"""
eval_id = 1
eval_id = 20
r = requests.get(
f"{API_URL}/evaluation/{eval_id}/notes",
headers=api_headers,
@ -81,14 +80,15 @@ def test_evaluation_notes(
)
assert r.status_code == 200
eval_notes = r.json()
for i in range(1, len(eval_notes)):
assert verify_fields(eval_notes[f"{i}"], EVALUATION_FIELDS)
assert isinstance(eval_notes[f"{i}"]["id"], int)
assert isinstance(eval_notes[f"{i}"]["etudid"], int)
assert isinstance(eval_notes[f"{i}"]["evaluation_id"], int)
assert isinstance(eval_notes[f"{i}"]["value"], float)
assert isinstance(eval_notes[f"{i}"]["comment"], str)
assert isinstance(eval_notes[f"{i}"]["date"], str)
assert isinstance(eval_notes[f"{i}"]["uid"], int)
assert eval_notes
for etudid, note in eval_notes.items():
assert int(etudid) == note["etudid"]
assert verify_fields(note, NOTES_FIELDS)
assert isinstance(note["etudid"], int)
assert isinstance(note["evaluation_id"], int)
assert isinstance(note["value"], float)
assert isinstance(note["comment"], str)
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,
"partition_id": 1,
"role_name": "Ens",
"start": "abc",
"uid": 1,
"version": "long",
"assiduite_id": 1,

View File

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

View File

@ -103,14 +103,14 @@ def run_sco_basic(verbose=False) -> FormSemestre:
# --- Saisie toutes les notes de l'évaluation
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"],
etudid=etud["etudid"],
note=NOTES_T[idx % len(NOTES_T)],
)
assert not existing_decisions
assert nb_suppress == 0
assert nb_changed == 1
assert len(etudids_changed) == 1
# --- Vérifie que les notes sont prises en compte:
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:
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"],
etudid=etud["etudid"],
note=NOTES_T[idx % len(NOTES_T)],
@ -158,7 +158,7 @@ def run_sco_basic(verbose=False) -> FormSemestre:
# Saisie des notes qui manquent:
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"],
etudid=etud["etudid"],
note=NOTES_T[idx % len(NOTES_T)],

View File

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