# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 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 time
import psycopg2
import flask
from flask import g, url_for, request
from flask_login import current_user
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,
NotesNotes,
ScolarNews,
)
from app.models.etudiants import Identite
from app.scodoc.sco_exceptions import (
AccessDenied,
InvalidNoteValue,
NoteProcessError,
ScoBugCatcher,
ScoException,
ScoInvalidParamError,
ScoValueError,
)
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_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
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
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(
note: str,
note_max,
note_min=scu.NOTES_MIN,
etudid: int = None,
absents: list[int] = None,
tosuppress: list[int] = None,
invalids: list[int] = None,
):
"""converti une valeur (chaine saisie) vers une note numérique (float)
Les listes absents, tosuppress et invalids sont modifiées
"""
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
tosuppress.append(etudid)
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 _displayNote(val):
"""Convert note from DB to viewable string.
Utilisé seulement pour I/O vers formulaires (sans perte de precision)
(Utiliser fmt_note pour les affichages)
"""
if val is None:
val = "ABS"
elif val == scu.NOTES_NEUTRALISE:
val = "EXC" # excuse, note neutralise
elif val == scu.NOTES_ATTENTE:
val = "ATT" # attente, note neutralise
elif val == scu.NOTES_SUPPRESS:
val = "SUPR"
else:
val = "%g" % val
return val
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: etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress
"""
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 module.module_type == ModuleType.MALUS:
note_min = -20.0
else:
raise ValueError("Invalid module type") # bug
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()
try:
etudid = int(etudid) #
except ValueError as exc:
raise ScoValueError(f"Code étudiant ({etudid}) invalide") from exc
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,
tosuppress=etudid_to_suppress,
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,
etudid_to_suppress,
)
def do_evaluation_upload_xls():
"""
Soumission d'un fichier XLS (evaluation_id, notefile)
"""
vals = scu.get_request_args()
evaluation_id = int(vals["evaluation_id"])
comment = vals["comment"]
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:
if not lines:
raise InvalidNoteValue()
# -- search eval code
n = len(lines)
i = 0
while i < n:
if not lines[i]:
diag.append("Erreur: format invalide (ligne vide ?)")
raise InvalidNoteValue()
f0 = lines[i][0].strip()
if f0 and f0[0] == "!":
break
i = i + 1
if i == n:
diag.append("Erreur: format invalide ! (pas de ligne evaluation_id)")
raise InvalidNoteValue()
eval_id_str = lines[i][0].strip()[1:]
try:
eval_id = int(eval_id_str)
except ValueError:
eval_id = None
if eval_id != evaluation_id:
diag.append(
f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{
eval_id_str}' != '{evaluation_id}')"""
)
raise InvalidNoteValue()
# --- get notes -> list (etudid, value)
# ignore toutes les lignes ne commençant pas par !
notes = []
ni = i + 1
try:
for line in lines[i + 1 :]:
if line:
cell0 = line[0].strip()
if cell0 and cell0[0] == "!":
etudid = cell0[1:]
if len(line) > 4:
val = line[4].strip()
else:
val = "" # ligne courte: cellule vide
if etudid:
notes.append((etudid, val))
ni += 1
except Exception as exc:
diag.append(
f"""Erreur: Ligne invalide ! (erreur ligne {ni})
{lines[ni]}"""
)
raise InvalidNoteValue() from exc
# -- check values
valid_notes, invalids, withoutnotes, absents, _ = _check_notes(
notes, evaluation
)
if invalids:
diag.append(
f"Erreur: la feuille contient {len(invalids)} notes invalides
{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, { len(absents)} absents, {nb_suppress} note supprimées)
""" if etudids_with_decisions: msg += """Important: il y avait déjà des décisions de jury enregistrées, qui sont peut-être à revoir suite à cette modification !
""" return 1, msg except InvalidNoteValue: if diag: msg = ( '(pas de notes modifiées)
" 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 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) if len(invalids) == 0: etudids_changed, _, _ = notes_add( current_user, evaluation.id, L, "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 sco_permissions_check.can_edit_notes(current_user, modimpl.id): 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()}Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC) n'a été rentrée seront affectés.
{len(valid_notes)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""} par ce changement de note.
""", 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 {modimpl.module.titre or ""}""", url=url, max_frequency=30 * 60, ) return f""" { html_sco_header.sco_header() }Confirmer la suppression des {nb_suppress} notes ? (peut affecter plusieurs groupes)
""" if existing_decisions: msg += """Important: il y a déjà des décisions de jury enregistrées, qui seront potentiellement à revoir suite à cette modification !
""" 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"""{nb_suppress} notes supprimées
"""] if existing_decisions: H.append( """Important: il y avait déjà des décisions de jury enregistrées, qui sont potentiellement à revoir suite à cette modification !
""" ) H += [ f"""continuer """ ] # news ScolarNews.add( typ=ScolarNews.NEWS_NOTE, obj=evaluation.moduleimpl.id, text=f"""Suppression des notes d'une évaluation dans {evaluation.moduleimpl.module.titre or 'module sans titre'} """, url=status_url, ) return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer() def notes_add( user: User, evaluation_id: int, notes: list, comment=None, do_it=True, check_inscription=True, ) -> tuple[list[int], int, list[int]]: """ 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) Return: tuple (etudids_changed, nb_suppress, etudids_with_decision) """ now = psycopg2.Timestamp(*time.localtime()[:6]) # Vérifie inscription et valeur note inscrits = { x[0] for x in sco_groups.do_evaluation_listeetuds_groups( evaluation_id, getallstudents=True, include_demdef=True ) } for etudid, value in notes: if check_inscription and (etudid not in inscrits): raise NoteProcessError(f"etudiant {etudid} non inscrit dans ce module") if (value is not None) and not isinstance(value, float): raise NoteProcessError( f"etudiant {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 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_decision = [] try: for etudid, value in notes: changed = False if etudid not in notes_db: # nouvelle note if value != scu.NOTES_SUPPRESS: if do_it: args = { "etudid": etudid, "evaluation_id": evaluation_id, "value": value, "comment": comment, "uid": user.id, "date": now, } 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 oldval = notes_db[etudid]["value"] if type(value) != type(oldval): changed = True elif isinstance(value, float) and ( abs(value - oldval) > scu.NOTES_PRECISION ): changed = True elif value != oldval: changed = True 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 """, {"etudid": etudid, "evaluation_id": evaluation_id}, ) args = { "etudid": etudid, "evaluation_id": evaluation_id, "value": value, "date": now, "comment": comment, "uid": user.id, } ndb.quote_dict(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, ) nb_suppress += 1 if changed: etudids_changed.append(etudid) if res.etud_has_decision(etudid): 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 def saisie_notes_tableur(evaluation_id, group_ids=()): """Saisie des notes via un fichier Excel""" evaluation = Evaluation.query.get_or_404(evaluation_id) moduleimpl_id = evaluation.moduleimpl.id formsemestre_id = evaluation.moduleimpl.formsemestre_id if not sco_permissions_check.can_edit_notes(current_user, moduleimpl_id): return ( html_sco_header.sco_header() + f"""
(vérifiez que le semestre n'est pas verrouillé et que vous avez l'autorisation d'effectuer cette opération)
""" + html_sco_header.sco_footer() ) page_title = "Saisie des notes" + ( f"""de {evaluation.description}""" if evaluation.description else "" ) # Informations sur les groupes à afficher: groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids=group_ids, formsemestre_id=formsemestre_id, select_all_when_unspecified=True, etat=None, ) H = [ html_sco_header.sco_header( page_title=page_title, javascripts=sco_groups_view.JAVASCRIPTS, cssstyles=sco_groups_view.CSSSTYLES, init_qtip=True, ), sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), """Saisie des notes par fichier""", ] # Menu choix groupe: H.append("""""") H.append(sco_groups_view.form_groups_choice(groups_infos)) H.append(" |
Le fichier doit être un fichier tableur obtenu via l'étape 1 ci-dessus, puis complété et enregistré au format Excel.
""" ) H.append(nf[1]) elif nf[0] == -1: H.append("Annulation
") elif nf[0] == 1: updiag = do_evaluation_upload_xls() if updiag[0]: H.append(updiag[1]) H.append( f"""Notes chargées. Revenir au tableau de bord du module Charger d'autres notes dans cette évaluation
""" ) else: H.append( f"""Notes non chargées !
{updiag[1]} """ ) # H.append("""(vérifiez que le semestre n'est pas verrouillé et que vous avez l'autorisation d'effectuer cette opération)
{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 ), '""") H.append(sco_groups_view.form_groups_choice(groups_infos)) H.append(' | ') H.append( htmlutils.make_menu( "Autres opérations", [ { "title": "Saisie 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( """ |
Les modifications sont enregistrées au fur et à mesure.