forked from ScoDoc/ScoDoc
1112 lines
38 KiB
Python
1112 lines
38 KiB
Python
##############################################################################
|
|
#
|
|
# Gestion scolarite IUT
|
|
#
|
|
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
|
#
|
|
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
|
#
|
|
##############################################################################
|
|
|
|
"""Saisie des notes
|
|
|
|
Formulaire revu en juillet 2016
|
|
"""
|
|
import html
|
|
import time
|
|
|
|
|
|
import flask
|
|
from flask import g, url_for
|
|
from flask_login import current_user
|
|
from flask_sqlalchemy.query import Query
|
|
import psycopg2
|
|
|
|
from app import db, 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,
|
|
Module,
|
|
ModuleImpl,
|
|
ScolarNews,
|
|
Assiduite,
|
|
)
|
|
from app.models.etudiants import Identite
|
|
|
|
from app.scodoc.sco_exceptions import (
|
|
AccessDenied,
|
|
NoteProcessError,
|
|
ScoException,
|
|
ScoInvalidParamError,
|
|
ScoValueError,
|
|
)
|
|
from app.scodoc import html_sco_header
|
|
from app.scodoc import htmlutils
|
|
from app.scodoc import sco_cache
|
|
from app.scodoc import sco_etud
|
|
from app.scodoc import sco_evaluation_db
|
|
from app.scodoc import sco_evaluations
|
|
from app.scodoc import sco_formsemestre_inscriptions
|
|
from app.scodoc import sco_groups
|
|
from app.scodoc import sco_groups_view
|
|
from app.scodoc import sco_undo_notes
|
|
import app.scodoc.notesdb as ndb
|
|
from app.scodoc.TrivialFormulator import 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(
|
|
note: str,
|
|
note_max: float,
|
|
note_min: float = scu.NOTES_MIN,
|
|
etudid: int = None,
|
|
absents: list[int] = None,
|
|
invalids: list[int] = None,
|
|
) -> tuple[float, bool]:
|
|
"""converti une valeur (chaine saisie) vers une note numérique (float)
|
|
Les listes absents et invalids sont modifiées.
|
|
Return:
|
|
note_value: float (valeur de la note ou code EXC, ATT, ...)
|
|
invalid: True si note invalide (eg hors barème)
|
|
"""
|
|
invalid = False
|
|
note_value = None
|
|
note = note.replace(",", ".")
|
|
if note[:3] == "ABS":
|
|
note_value = None
|
|
absents.append(etudid)
|
|
elif note[:3] == "NEU" or note[:3] == "EXC":
|
|
note_value = scu.NOTES_NEUTRALISE
|
|
elif note[:3] == "ATT":
|
|
note_value = scu.NOTES_ATTENTE
|
|
elif note[:3] == "SUP":
|
|
note_value = scu.NOTES_SUPPRESS
|
|
else:
|
|
try:
|
|
note_value = float(note)
|
|
if (note_value < note_min) or (note_value > note_max):
|
|
raise ValueError
|
|
except ValueError:
|
|
invalids.append(etudid)
|
|
invalid = True
|
|
|
|
return note_value, invalid
|
|
|
|
|
|
def check_notes(
|
|
notes: list[(int, float | str)], evaluation: Evaluation
|
|
) -> tuple[list[tuple[int, float]], list[int], list[int], list[int], list[int]]:
|
|
"""Vérifie et converti les valeurs des notes pour une évaluation.
|
|
|
|
notes: list of tuples (etudid, value)
|
|
evaluation: target
|
|
|
|
Returns
|
|
valid_notes: list of valid notes (etudid, float value)
|
|
and 4 lists of etudid:
|
|
etudids_invalids : etudid avec notes invalides
|
|
etudids_without_notes: etudid sans notes (champs vides)
|
|
etudids_absents : etudid avec note ABS
|
|
etudids_non_inscrits : etudid non inscrits à ce module
|
|
(ne considère pas l'inscr. au semestre)
|
|
"""
|
|
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,
|
|
):
|
|
if evaluation.evaluation_type == Evaluation.EVALUATION_BONUS:
|
|
note_min, note_max = -20, 20
|
|
else:
|
|
note_min = scu.NOTES_MIN
|
|
elif module.module_type == ModuleType.MALUS:
|
|
note_min = -20.0
|
|
else:
|
|
raise ValueError("Invalid module type") # bug
|
|
# Vérifie inscription au module (même DEM/DEF)
|
|
etudids_inscrits_mod = {
|
|
i.etudid for i in evaluation.moduleimpl.query_inscriptions()
|
|
}
|
|
valid_notes = []
|
|
etudids_invalids = []
|
|
etudids_without_notes = []
|
|
etudids_absents = []
|
|
etudids_non_inscrits = []
|
|
|
|
for etudid, note in notes:
|
|
if etudid not in etudids_inscrits_mod:
|
|
# Si inscrit au formsemestre mais pas au module,
|
|
# accepte note "NI" uniquement (pour les imports excel multi-éval)
|
|
if (
|
|
etudid not in evaluation.moduleimpl.formsemestre.etudids_actifs()[0]
|
|
) or note != "NI":
|
|
etudids_non_inscrits.append(etudid)
|
|
continue
|
|
try:
|
|
etudid = int(etudid) #
|
|
except ValueError as exc:
|
|
raise ScoValueError(f"Code étudiant ({etudid}) invalide") from exc
|
|
note = str(note).strip().upper()
|
|
if note[:3] == "DEM":
|
|
continue # skip !
|
|
if note:
|
|
value, invalid = convert_note_from_string(
|
|
note,
|
|
note_max,
|
|
note_min=note_min,
|
|
etudid=etudid,
|
|
absents=etudids_absents,
|
|
invalids=etudids_invalids,
|
|
)
|
|
if not invalid:
|
|
valid_notes.append((etudid, value))
|
|
else:
|
|
etudids_without_notes.append(etudid)
|
|
return (
|
|
valid_notes,
|
|
etudids_invalids,
|
|
etudids_without_notes,
|
|
etudids_absents,
|
|
etudids_non_inscrits,
|
|
)
|
|
|
|
|
|
def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -> bool:
|
|
"""Enregistre la note d'un seul étudiant
|
|
value: valeur externe (float ou str)
|
|
"""
|
|
if not evaluation.moduleimpl.can_edit_notes(current_user):
|
|
raise AccessDenied(f"Modification des notes impossible pour {current_user}")
|
|
# Convert and check value
|
|
notes, invalids, _, _, _ = check_notes([(etud.id, value)], evaluation)
|
|
if len(invalids) == 0:
|
|
etudids_changed, _, _, _ = notes_add(
|
|
current_user, evaluation.id, notes, "Initialisation notes"
|
|
)
|
|
if len(etudids_changed) == 1:
|
|
return True
|
|
return False # error
|
|
|
|
|
|
def do_evaluation_set_missing(
|
|
evaluation_id, value, dialog_confirmed=False, group_ids_str: str = ""
|
|
):
|
|
"""Initialisation des notes manquantes"""
|
|
evaluation = Evaluation.query.get_or_404(evaluation_id)
|
|
modimpl = evaluation.moduleimpl
|
|
# Check access
|
|
# (admin, respformation, and responsable_id)
|
|
if not modimpl.can_edit_notes(current_user):
|
|
raise AccessDenied(f"Modification des notes impossible pour {current_user}")
|
|
#
|
|
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
|
|
if not group_ids_str:
|
|
groups = None
|
|
else:
|
|
group_ids = [int(x) for x in str(group_ids_str).split(",")]
|
|
groups = sco_groups.listgroups(group_ids)
|
|
|
|
etudid_etats = sco_groups.do_evaluation_listeetuds_groups(
|
|
evaluation_id,
|
|
getallstudents=groups is None,
|
|
groups=groups,
|
|
include_demdef=False,
|
|
)
|
|
|
|
notes = []
|
|
for etudid, _ in etudid_etats: # pour tous les inscrits
|
|
if etudid not in notes_db: # pas de note
|
|
notes.append((etudid, value))
|
|
# Convert and check values
|
|
valid_notes, invalids, _, _, _ = check_notes(notes, evaluation)
|
|
dest_url = url_for(
|
|
"notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id
|
|
)
|
|
diag = ""
|
|
if len(invalids) > 0:
|
|
diag = f"Valeur {value} invalide ou hors barème"
|
|
if diag:
|
|
return f"""
|
|
{html_sco_header.sco_header()}
|
|
<h2>{diag}</h2>
|
|
<p><a href="{ dest_url }">
|
|
Recommencer</a>
|
|
</p>
|
|
{html_sco_header.sco_footer()}
|
|
"""
|
|
# Confirm action
|
|
if not dialog_confirmed:
|
|
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(valid_notes)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""}
|
|
par ce changement de note.</b>
|
|
</p>
|
|
""",
|
|
dest_url="",
|
|
cancel_url=dest_url,
|
|
parameters={
|
|
"evaluation_id": evaluation_id,
|
|
"value": value,
|
|
"group_ids_str": group_ids_str,
|
|
},
|
|
)
|
|
# ok
|
|
comment = "Initialisation notes manquantes"
|
|
etudids_changed, _, _, _ = notes_add(
|
|
current_user, evaluation_id, valid_notes, comment
|
|
)
|
|
# news
|
|
url = url_for(
|
|
"notes.moduleimpl_status",
|
|
scodoc_dept=g.scodoc_dept,
|
|
moduleimpl_id=evaluation.moduleimpl_id,
|
|
)
|
|
ScolarNews.add(
|
|
typ=ScolarNews.NEWS_NOTE,
|
|
obj=evaluation.moduleimpl_id,
|
|
text=f"""Initialisation notes dans <a href="{url}">{modimpl.module.titre or ""}</a>""",
|
|
url=url,
|
|
max_frequency=30 * 60,
|
|
)
|
|
return f"""
|
|
{ html_sco_header.sco_header() }
|
|
<h2>{len(etudids_changed)} notes changées</h2>
|
|
<ul>
|
|
<li><a class="stdlink" href="{dest_url}">
|
|
Revenir au formulaire de saisie des notes</a>
|
|
</li>
|
|
<li><a class="stdlink" href="{
|
|
url_for(
|
|
"notes.moduleimpl_status",
|
|
scodoc_dept=g.scodoc_dept,
|
|
moduleimpl_id=evaluation.moduleimpl_id,
|
|
)}">Tableau de bord du module</a>
|
|
</li>
|
|
</ul>
|
|
{ html_sco_header.sco_footer() }
|
|
"""
|
|
|
|
|
|
def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
|
|
"suppress all notes in this eval"
|
|
evaluation = Evaluation.query.get_or_404(evaluation_id)
|
|
|
|
if evaluation.moduleimpl.can_edit_notes(current_user, allow_ens=False):
|
|
# On a le droit de modifier toutes les notes
|
|
# recupere les etuds ayant une note
|
|
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
|
|
elif evaluation.moduleimpl.can_edit_notes(current_user, allow_ens=True):
|
|
# Enseignant associé au module: ne peut supprimer que les notes qu'il a saisi
|
|
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
|
|
evaluation_id, by_uid=current_user.id
|
|
)
|
|
else:
|
|
raise AccessDenied(f"Modification des notes impossible pour {current_user}")
|
|
|
|
notes = [(etudid, scu.NOTES_SUPPRESS) for etudid in notes_db.keys()]
|
|
|
|
status_url = url_for(
|
|
"notes.moduleimpl_status",
|
|
scodoc_dept=g.scodoc_dept,
|
|
moduleimpl_id=evaluation.moduleimpl_id,
|
|
)
|
|
|
|
if not dialog_confirmed:
|
|
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 ?
|
|
<em>(peut affecter plusieurs groupes)</em>
|
|
</p>
|
|
"""
|
|
|
|
if existing_decisions:
|
|
msg += """<p class="warning">Important: il y a déjà des décisions de
|
|
jury enregistrées, qui seront potentiellement à revoir suite à
|
|
cette modification !</p>"""
|
|
return scu.confirm_dialog(
|
|
msg,
|
|
dest_url="",
|
|
OK="Supprimer les notes",
|
|
cancel_url=status_url,
|
|
parameters={"evaluation_id": evaluation_id},
|
|
)
|
|
|
|
# modif
|
|
etudids_changed, nb_suppress, existing_decisions, _ = notes_add(
|
|
current_user,
|
|
evaluation_id,
|
|
notes,
|
|
comment="effacer tout",
|
|
check_inscription=False,
|
|
)
|
|
assert len(etudids_changed) == nb_suppress
|
|
H = [f"""<p>{nb_suppress} notes supprimées</p>"""]
|
|
if existing_decisions:
|
|
H.append(
|
|
"""<p class="warning">Important: il y avait déjà des décisions
|
|
de jury enregistrées, qui sont potentiellement à revoir suite
|
|
à cette modification !
|
|
</p>"""
|
|
)
|
|
H += [
|
|
f"""<p><a class="stdlink" href="{status_url}">continuer</a>
|
|
"""
|
|
]
|
|
# news
|
|
if nb_suppress:
|
|
ScolarNews.add(
|
|
typ=ScolarNews.NEWS_NOTE,
|
|
obj=evaluation.moduleimpl.id,
|
|
text=f"""Suppression des notes d'une évaluation dans
|
|
<a class="stdlink" href="{status_url}"
|
|
>{evaluation.moduleimpl.module.titre or 'module sans titre'}</a>
|
|
""",
|
|
url=status_url,
|
|
)
|
|
|
|
return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer()
|
|
|
|
|
|
def _check_inscription(
|
|
etudid: int,
|
|
etudids_inscrits_sem: list[int],
|
|
etudids_inscrits_mod: set[int],
|
|
messages: list[str] | None = None,
|
|
) -> str:
|
|
"""Vérifie inscription de etudid au moduleimpl et au semestre, et
|
|
- si étudiant non inscrit au semestre ou au module: lève NoteProcessError
|
|
"""
|
|
msg_err = ""
|
|
if etudid not in etudids_inscrits_sem:
|
|
msg_err = "non inscrit au semestre"
|
|
elif etudid not in etudids_inscrits_mod:
|
|
msg_err = "non inscrit au module"
|
|
if msg_err:
|
|
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
|
|
msg = f"étudiant {etud.nomprenom if etud else etudid} {msg_err}"
|
|
log(f"notes_add: {etudid} {msg}: aborting")
|
|
raise NoteProcessError(msg)
|
|
|
|
|
|
def notes_add(
|
|
user: User,
|
|
evaluation_id: int,
|
|
notes: list,
|
|
comment=None,
|
|
do_it=True,
|
|
check_inscription=True,
|
|
) -> tuple[list[int], int, list[int], list[str]]:
|
|
"""
|
|
Insert or update notes
|
|
notes is a list of tuples (etudid,value)
|
|
If do_it is False, simulate the process and returns the number of values that
|
|
WOULD be changed or suppressed.
|
|
Nota:
|
|
- si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log)
|
|
|
|
Raise NoteProcessError si note invalide ou étudiant non inscrit.
|
|
|
|
Return: tuple (etudids_changed, nb_suppress, etudids_with_decision, messages)
|
|
|
|
messages = list de messages d'avertissement/information pour l'utilisateur
|
|
"""
|
|
evaluation = Evaluation.get_evaluation(evaluation_id)
|
|
now = psycopg2.Timestamp(*time.localtime()[:6])
|
|
messages = []
|
|
# Vérifie inscription au module (même DEM/DEF)
|
|
etudids_inscrits_mod = {
|
|
i.etudid for i in evaluation.moduleimpl.query_inscriptions()
|
|
}
|
|
# Les étudiants inscrits au semestre et ceux "actifs" (ni DEM ni DEF)
|
|
etudids_inscrits_sem, etudids_actifs = (
|
|
evaluation.moduleimpl.formsemestre.etudids_actifs()
|
|
)
|
|
for etudid, value in notes:
|
|
|
|
if check_inscription:
|
|
_check_inscription(etudid, etudids_inscrits_sem, etudids_inscrits_mod)
|
|
|
|
if (value is not None) and not isinstance(value, float):
|
|
log(f"notes_add: {etudid} valeur de note invalide ({value}): aborting")
|
|
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
|
|
raise NoteProcessError(
|
|
f"etudiant {etud.nomprenom if etud else etudid}: valeur de note invalide ({value})"
|
|
)
|
|
# Recherche notes existantes
|
|
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
|
|
# Met a jour la base
|
|
cnx = ndb.GetDBConnexion()
|
|
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
|
|
etudids_changed = []
|
|
nb_suppress = 0
|
|
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_decision = []
|
|
try:
|
|
for etudid, value in notes:
|
|
changed, suppressed = _record_note(
|
|
cursor,
|
|
notes_db,
|
|
etudid,
|
|
evaluation_id,
|
|
value,
|
|
comment=comment,
|
|
user=user,
|
|
date=now,
|
|
do_it=do_it,
|
|
)
|
|
|
|
if suppressed:
|
|
nb_suppress += 1
|
|
|
|
if changed:
|
|
etudids_changed.append(etudid)
|
|
# si change sur DEM/DEF ajoute message warning aux messages
|
|
if etudid not in etudids_actifs: # DEM ou DEF
|
|
etud = (
|
|
Identite.query.get(etudid) if isinstance(etudid, int) else None
|
|
)
|
|
messages.append(
|
|
f"""étudiant {etud.nomprenom if etud else etudid
|
|
} démissionnaire ou défaillant (note enregistrée)"""
|
|
)
|
|
|
|
if res.etud_has_decision(etudid, include_rcues=False):
|
|
etudids_with_decision.append(etudid)
|
|
|
|
except Exception as exc:
|
|
log("*** exception in notes_add")
|
|
if do_it:
|
|
cnx.rollback() # abort
|
|
# inval cache
|
|
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
|
|
sco_cache.EvaluationCache.delete(evaluation_id)
|
|
raise ScoException from exc
|
|
|
|
if do_it:
|
|
cnx.commit()
|
|
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
|
|
sco_cache.EvaluationCache.delete(evaluation_id)
|
|
|
|
return etudids_changed, nb_suppress, etudids_with_decision, messages
|
|
|
|
|
|
def _record_note(
|
|
cursor,
|
|
notes_db,
|
|
etudid: int,
|
|
evaluation_id: int,
|
|
value: float,
|
|
comment: str = "",
|
|
user: User = None,
|
|
date=None,
|
|
do_it=False,
|
|
):
|
|
"Enregistrement de la note en base"
|
|
changed = False
|
|
suppressed = False
|
|
args = {
|
|
"etudid": etudid,
|
|
"evaluation_id": evaluation_id,
|
|
"value": value,
|
|
# convention scodoc7 quote comment:
|
|
"comment": (html.escape(comment) if isinstance(comment, str) else comment),
|
|
"uid": user.id,
|
|
"date": date,
|
|
}
|
|
if etudid not in notes_db:
|
|
# nouvelle note
|
|
if value != scu.NOTES_SUPPRESS:
|
|
if do_it:
|
|
# 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
|
|
oldval = notes_db[etudid]["value"]
|
|
changed = (
|
|
(not isinstance(value, type(oldval)))
|
|
or (
|
|
isinstance(value, float) and (abs(value - oldval) > scu.NOTES_PRECISION)
|
|
)
|
|
or value != oldval
|
|
)
|
|
if changed:
|
|
# recopie l'ancienne note dans notes_notes_log, puis update
|
|
if do_it:
|
|
cursor.execute(
|
|
"""INSERT INTO notes_notes_log
|
|
(etudid,evaluation_id,value,comment,date,uid)
|
|
SELECT etudid, evaluation_id, value, comment, date, uid
|
|
FROM notes_notes
|
|
WHERE etudid=%(etudid)s
|
|
and evaluation_id=%(evaluation_id)s
|
|
""",
|
|
args,
|
|
)
|
|
if value != scu.NOTES_SUPPRESS:
|
|
if do_it:
|
|
cursor.execute(
|
|
"""UPDATE notes_notes
|
|
SET value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
|
|
WHERE etudid = %(etudid)s
|
|
and evaluation_id = %(evaluation_id)s
|
|
""",
|
|
args,
|
|
)
|
|
else: # suppression ancienne note
|
|
if do_it:
|
|
log(
|
|
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
|
|
""",
|
|
args,
|
|
)
|
|
# garde trace de la suppression dans l'historique:
|
|
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)
|
|
""",
|
|
args,
|
|
)
|
|
suppressed = True
|
|
return changed, suppressed
|
|
|
|
|
|
# Nouveau formulaire saisie notes (2016)
|
|
def saisie_notes(evaluation_id: int, group_ids: list = None):
|
|
"""Formulaire saisie notes d'une évaluation pour un groupe"""
|
|
if not isinstance(evaluation_id, int):
|
|
raise ScoInvalidParamError()
|
|
group_ids = [int(group_id) for group_id in (group_ids or [])]
|
|
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
|
|
if evaluation is None:
|
|
raise ScoValueError("évaluation inexistante")
|
|
modimpl = evaluation.moduleimpl
|
|
moduleimpl_status_url = url_for(
|
|
"notes.moduleimpl_status",
|
|
scodoc_dept=g.scodoc_dept,
|
|
moduleimpl_id=evaluation.moduleimpl_id,
|
|
)
|
|
# Check access
|
|
# (admin, respformation, and responsable_id)
|
|
if not evaluation.moduleimpl.can_edit_notes(current_user):
|
|
return f"""
|
|
{html_sco_header.sco_header()}
|
|
<h2>Modification des notes impossible pour {current_user.user_name}</h2>
|
|
|
|
<p>(vérifiez que le semestre n'est pas verrouillé et que vous
|
|
avez l'autorisation d'effectuer cette opération)</p>
|
|
<p><a href="{ moduleimpl_status_url }">Continuer</a>
|
|
</p>
|
|
{html_sco_header.sco_footer()}
|
|
"""
|
|
|
|
# Informations sur les groupes à afficher:
|
|
groups_infos = sco_groups_view.DisplayedGroupsInfos(
|
|
group_ids=group_ids,
|
|
formsemestre_id=modimpl.formsemestre_id,
|
|
select_all_when_unspecified=True,
|
|
etat=None,
|
|
)
|
|
|
|
page_title = (
|
|
f'Saisie "{evaluation.description}"'
|
|
if evaluation.description
|
|
else "Saisie des notes"
|
|
)
|
|
# HTML page:
|
|
H = [
|
|
html_sco_header.sco_header(
|
|
page_title=page_title,
|
|
javascripts=sco_groups_view.JAVASCRIPTS + ["js/saisie_notes.js"],
|
|
cssstyles=sco_groups_view.CSSSTYLES,
|
|
init_qtip=True,
|
|
),
|
|
sco_evaluations.evaluation_describe(
|
|
evaluation_id=evaluation_id, link_saisie=False
|
|
),
|
|
'<div id="saisie_notes"><span class="eval_title">Saisie des notes</span>',
|
|
]
|
|
H.append("""<div id="group-tabs"><table><tr><td>""")
|
|
H.append(sco_groups_view.form_groups_choice(groups_infos))
|
|
H.append('</td><td style="padding-left: 35px;">')
|
|
H.append(
|
|
htmlutils.make_menu(
|
|
"Autres opérations",
|
|
[
|
|
{
|
|
"title": "Saisir par fichier tableur",
|
|
"id": "menu_saisie_tableur",
|
|
"endpoint": "notes.saisie_notes_tableur",
|
|
"args": {
|
|
"evaluation_id": evaluation.id,
|
|
"group_ids": groups_infos.group_ids,
|
|
},
|
|
},
|
|
{
|
|
"title": "Voir toutes les notes du module",
|
|
"endpoint": "notes.evaluation_listenotes",
|
|
"args": {"moduleimpl_id": evaluation.moduleimpl_id},
|
|
},
|
|
{
|
|
"title": "Effacer toutes les notes de cette évaluation",
|
|
"endpoint": "notes.evaluation_suppress_alln",
|
|
"args": {"evaluation_id": evaluation.id},
|
|
},
|
|
],
|
|
alone=True,
|
|
)
|
|
)
|
|
H.append(
|
|
"""
|
|
</td>
|
|
<td style="padding-left: 35px;">
|
|
<button class="btn_masquer_DEM">Masquer les DEM</button>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
<style>
|
|
.btn_masquer_DEM{
|
|
font-size: 12px;
|
|
}
|
|
body.masquer_DEM .btn_masquer_DEM{
|
|
background: #009688;
|
|
color: #fff;
|
|
}
|
|
body.masquer_DEM .etud_dem{
|
|
display: none !important;
|
|
}
|
|
</style>
|
|
"""
|
|
)
|
|
|
|
# Le formulaire de saisie des notes:
|
|
form = _form_saisie_notes(
|
|
evaluation, modimpl, groups_infos, destination=moduleimpl_status_url
|
|
)
|
|
if form is None:
|
|
return flask.redirect(moduleimpl_status_url)
|
|
H.append(form)
|
|
#
|
|
H.append("</div>") # /saisie_notes
|
|
|
|
H.append(
|
|
"""<div class="sco_help">
|
|
<p>Les modifications sont enregistrées au fur et à mesure.
|
|
Vous pouvez aussi copier/coller depuis un tableur ou autre logiciel.
|
|
</p>
|
|
<h4>Codes spéciaux:</h4>
|
|
<ul>
|
|
<li>ABS: absent (compte comme un zéro)</li>
|
|
<li>EXC: excusé (note neutralisée)</li>
|
|
<li>SUPR: pour supprimer une note existante</li>
|
|
<li>ATT: note en attente (permet de publier une évaluation avec des notes manquantes)</li>
|
|
</ul>
|
|
</div>"""
|
|
)
|
|
|
|
H.append(html_sco_header.sco_footer())
|
|
return "\n".join(H)
|
|
|
|
|
|
def get_sorted_etuds_notes(
|
|
evaluation: Evaluation, etudids: list, formsemestre_id: int
|
|
) -> list[dict]:
|
|
"""Liste d'infos sur les notes existantes pour les étudiants indiqués"""
|
|
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
|
|
cnx = ndb.GetDBConnexion()
|
|
etuds = []
|
|
for etudid in etudids:
|
|
# infos identite etudiant
|
|
e = sco_etud.etudident_list(cnx, {"etudid": etudid})[0]
|
|
etud = Identite.get_etud(etudid)
|
|
# TODO: refactor et eliminer etudident_list.
|
|
e["etud"] = etud # utilisé seulement pour le tri -- a refactorer
|
|
sco_etud.format_etud_ident(e)
|
|
etuds.append(e)
|
|
# infos inscription dans ce semestre
|
|
e["inscr"] = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
|
|
{"etudid": etudid, "formsemestre_id": formsemestre_id}
|
|
)[0]
|
|
# Groupes auxquels appartient cet étudiant:
|
|
e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id)
|
|
|
|
# Information sur absence
|
|
warn_abs_lst: str = ""
|
|
if evaluation.date_debut is not None and evaluation.date_fin is not None:
|
|
assiduites_etud: Query = etud.assiduites.filter(
|
|
Assiduite.etat == scu.EtatAssiduite.ABSENT,
|
|
Assiduite.date_debut <= evaluation.date_fin,
|
|
Assiduite.date_fin >= evaluation.date_debut,
|
|
)
|
|
premiere_assi: Assiduite = assiduites_etud.first()
|
|
if premiere_assi is not None:
|
|
warn_abs_lst: str = (
|
|
f"absent {'justifié' if premiere_assi.est_just else ''}"
|
|
)
|
|
|
|
e["absinfo"] = '<span class="sn_abs">' + warn_abs_lst + "</span> "
|
|
|
|
# Note actuelle de l'étudiant:
|
|
if etudid in notes_db:
|
|
e["val"] = scu.fmt_note(
|
|
notes_db[etudid]["value"], fixed_precision_str=False
|
|
)
|
|
user = (
|
|
User.query.get(notes_db[etudid]["uid"])
|
|
if notes_db[etudid]["uid"]
|
|
else None
|
|
)
|
|
e["explanation"] = (
|
|
f"""{
|
|
notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M")
|
|
} par {user.get_nomplogin() if user else '?'
|
|
} {(' : ' + notes_db[etudid]["comment"]) if notes_db[etudid]["comment"] else ''}
|
|
"""
|
|
)
|
|
else:
|
|
e["val"] = ""
|
|
e["explanation"] = ""
|
|
# Démission ?
|
|
if e["inscr"]["etat"] == "D":
|
|
# if not e['val']:
|
|
e["val"] = "DEM"
|
|
e["explanation"] = "Démission"
|
|
|
|
etuds.sort(key=lambda x: x["etud"].sort_key)
|
|
|
|
return etuds
|
|
|
|
|
|
def _form_saisie_notes(
|
|
evaluation: Evaluation, modimpl: ModuleImpl, groups_infos, destination=""
|
|
):
|
|
"""Formulaire HTML saisie des notes dans l'évaluation du moduleimpl
|
|
pour les groupes indiqués.
|
|
|
|
On charge tous les étudiants, ne seront montrés que ceux
|
|
des groupes sélectionnés grace a un filtre en javascript.
|
|
"""
|
|
formsemestre_id = modimpl.formsemestre_id
|
|
formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre
|
|
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
|
|
etudids = [
|
|
x[0]
|
|
for x in sco_groups.do_evaluation_listeetuds_groups(
|
|
evaluation.id, getallstudents=True, include_demdef=True
|
|
)
|
|
]
|
|
if not etudids:
|
|
return '<div class="ue_warning"><span>Aucun étudiant sélectionné !</span></div>'
|
|
|
|
# Décisions de jury existantes ?
|
|
# en BUT on ne considère pas les RCUEs car ils peuvenut avoir été validés depuis
|
|
# d'autres semestres (les validations de RCUE n'indiquent pas si elles sont "externes")
|
|
decisions_jury = {
|
|
etudid: res.etud_has_decision(etudid, include_rcues=False) for etudid in etudids
|
|
}
|
|
|
|
# Nb de décisions de jury (pour les inscrits à l'évaluation):
|
|
nb_decisions = sum(decisions_jury.values())
|
|
|
|
etuds = get_sorted_etuds_notes(evaluation, etudids, formsemestre_id)
|
|
|
|
# Build form:
|
|
descr = [
|
|
("evaluation_id", {"default": evaluation.id, "input_type": "hidden"}),
|
|
("formsemestre_id", {"default": formsemestre_id, "input_type": "hidden"}),
|
|
(
|
|
"group_ids",
|
|
{"default": groups_infos.group_ids, "input_type": "hidden", "type": "list"},
|
|
),
|
|
# ('note_method', { 'default' : note_method, 'input_type' : 'hidden'}),
|
|
("comment", {"size": 44, "title": "Commentaire", "return_focus_next": True}),
|
|
("changed", {"default": "0", "input_type": "hidden"}), # changed in JS
|
|
]
|
|
if modimpl.module.module_type in (
|
|
ModuleType.STANDARD,
|
|
ModuleType.RESSOURCE,
|
|
ModuleType.SAE,
|
|
):
|
|
descr.append(
|
|
(
|
|
"s3",
|
|
{
|
|
"input_type": "text", # affiche le barème
|
|
"title": "Notes ",
|
|
"cssclass": "formnote_bareme",
|
|
"readonly": True,
|
|
"default": f" / {evaluation.note_max:g}",
|
|
},
|
|
)
|
|
)
|
|
elif modimpl.module.module_type == ModuleType.MALUS:
|
|
descr.append(
|
|
(
|
|
"s3",
|
|
{
|
|
"input_type": "text", # affiche le barème
|
|
"title": "",
|
|
"cssclass": "formnote_bareme",
|
|
"readonly": True,
|
|
"default": "Points de malus (soustraits à la moyenne de l'UE, entre -20 et 20)",
|
|
},
|
|
)
|
|
)
|
|
else:
|
|
raise ValueError(f"invalid module type ({modimpl.module.module_type})") # bug
|
|
|
|
initvalues = {}
|
|
for e in etuds:
|
|
etudid = e["etudid"]
|
|
disabled = e["val"] == "DEM"
|
|
etud_classes = []
|
|
if disabled:
|
|
classdem = " etud_dem"
|
|
etud_classes.append("etud_dem")
|
|
disabled_attr = f'disabled="{disabled}"'
|
|
else:
|
|
classdem = ""
|
|
disabled_attr = ""
|
|
# attribue a chaque element une classe css par groupe:
|
|
for group_info in e["groups"]:
|
|
etud_classes.append("group-" + str(group_info["group_id"]))
|
|
|
|
label = f"""<span class="{classdem}">{e["civilite_str"]} {
|
|
scu.format_nomprenom(e, reverse=True)}</span>"""
|
|
|
|
# Historique des saisies de notes:
|
|
explanation = (
|
|
""
|
|
if disabled
|
|
else f"""<span id="hist_{etudid}">{
|
|
get_note_history_menu(evaluation.id, etudid)
|
|
}</span>"""
|
|
)
|
|
explanation = e["absinfo"] + explanation
|
|
|
|
# Lien modif decision de jury:
|
|
explanation += f'<span id="jurylink_{etudid}" class="jurylink"></span>'
|
|
|
|
# Valeur actuelle du champ:
|
|
initvalues["note_" + str(etudid)] = e["val"]
|
|
label_link = f'<a class="etudinfo" id="{etudid}">{label}</a>'
|
|
|
|
# Element de formulaire:
|
|
descr.append(
|
|
(
|
|
"note_" + str(etudid),
|
|
{
|
|
"size": 5,
|
|
"title": label_link,
|
|
"explanation": explanation,
|
|
"return_focus_next": True,
|
|
"attributes": [
|
|
f'class="note{classdem}"',
|
|
disabled_attr,
|
|
f'''data-last-saved-value="{e['val']}"''',
|
|
f'''data-orig-value="{e["val"]}"''',
|
|
f'data-etudid="{etudid}"',
|
|
],
|
|
"template": """<tr%(item_dom_attr)s class="etud_elem """
|
|
+ " ".join(etud_classes)
|
|
+ """"><td class="tf-fieldlabel">%(label)s</td>
|
|
<td class="tf-field">%(elem)s</td></tr>
|
|
""",
|
|
},
|
|
)
|
|
)
|
|
#
|
|
H = []
|
|
if nb_decisions > 0:
|
|
H.append(
|
|
f"""<div class="saisie_warn">
|
|
<ul class="tf-msg">
|
|
<li class="tf-msg">Attention: il y a déjà des <b>décisions de jury</b> enregistrées pour
|
|
{nb_decisions} étudiants. Après changement des notes, vérifiez la situation !</li>
|
|
</ul>
|
|
</div>"""
|
|
)
|
|
|
|
tf = TF(
|
|
destination,
|
|
scu.get_request_args(),
|
|
descr,
|
|
initvalues=initvalues,
|
|
submitbutton=False,
|
|
formid="formnotes",
|
|
method="GET",
|
|
)
|
|
H.append(tf.getform()) # check and init
|
|
H.append(
|
|
f"""<a href="{url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept,
|
|
moduleimpl_id=modimpl.id)
|
|
}" class="btn btn-primary">Terminer</a>
|
|
"""
|
|
)
|
|
if tf.canceled():
|
|
return None
|
|
elif (not tf.submitted()) or not tf.result:
|
|
# ajout formulaire saisie notes manquantes
|
|
H.append(
|
|
f"""
|
|
<div>
|
|
<form id="do_evaluation_set_missing" action="do_evaluation_set_missing" method="POST">
|
|
Mettre les notes manquantes à
|
|
<input type="text" size="5" name="value"/>
|
|
<input type="submit" value="OK"/>
|
|
<input type="hidden" name="evaluation_id" value="{evaluation.id}"/>
|
|
<input class="group_ids_str" type="hidden" name="group_ids_str" value="{
|
|
",".join([str(x) for x in groups_infos.group_ids])
|
|
}"/>
|
|
<em>ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente"</em>
|
|
</form>
|
|
</div>
|
|
"""
|
|
)
|
|
# affiche formulaire
|
|
return "\n".join(H)
|
|
else:
|
|
# form submission
|
|
# rien à faire
|
|
return None
|
|
|
|
|
|
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=evaluation.moduleimpl_id,
|
|
_external=True,
|
|
)
|
|
# Check access: admin, respformation, or responsable_id
|
|
if not evaluation.moduleimpl.can_edit_notes(current_user):
|
|
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, messages = 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"""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
|
|
},
|
|
"messages": messages,
|
|
}
|
|
else:
|
|
result = {
|
|
"etudids_changed": [],
|
|
"etudids_with_decision": [],
|
|
"history_menu": [],
|
|
"messages": [],
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
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:
|
|
return ""
|
|
|
|
H = []
|
|
if len(history) > 1:
|
|
H.append(
|
|
f'<select data-etudid="{etudid}" class="note_history" onchange="change_history(this);">'
|
|
)
|
|
envir = "select"
|
|
item = "option"
|
|
else:
|
|
# pas de menu
|
|
H.append('<span class="history">')
|
|
envir = "span"
|
|
item = "span"
|
|
|
|
first = True
|
|
for i in history:
|
|
jt = f"""{i["date"].strftime("le %d/%m/%Y à %H:%M")} ({i["user_name"]})"""
|
|
dispnote = scu.fmt_note(i["value"], fixed_precision_str=False)
|
|
if first:
|
|
nv = "" # ne repete pas la valeur de la note courante
|
|
else:
|
|
# ancienne valeur
|
|
nv = f": {dispnote}"
|
|
first = False
|
|
if i["comment"]:
|
|
comment = f' <span class="histcomment">{i["comment"]}</span>'
|
|
else:
|
|
comment = ""
|
|
H.append(f'<{item} data-note="{dispnote}">{jt} {nv}{comment}</{item}>')
|
|
|
|
H.append(f"</{envir}>")
|
|
return "\n".join(H)
|