Saisie notes multi-évaluations. Closes #942.

This commit is contained in:
Emmanuel Viennet 2024-07-14 22:20:37 +02:00
parent 94a77abc92
commit 9b825c0fb1
9 changed files with 486 additions and 198 deletions

View File

@ -267,10 +267,12 @@ class Evaluation(models.ScoDocModel):
@classmethod @classmethod
def get_evaluation( def get_evaluation(
cls, evaluation_id: int | str, dept_id: int = None cls, evaluation_id: int | str, dept_id: int = None, accept_none=False
) -> "Evaluation": ) -> "Evaluation":
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant.""" """Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant.
from app.models import FormSemestre, ModuleImpl Si accept_none, return None si l'id est invalide ou n'existe pas.
"""
from app.models import FormSemestre
if not isinstance(evaluation_id, int): if not isinstance(evaluation_id, int):
try: try:
@ -282,6 +284,8 @@ class Evaluation(models.ScoDocModel):
query = cls.query.filter_by(id=evaluation_id) query = cls.query.filter_by(id=evaluation_id)
if dept_id is not None: if dept_id is not None:
query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id) query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id)
if accept_none:
return query.first()
return query.first_or_404() return query.first_or_404()
@classmethod @classmethod

View File

@ -60,12 +60,12 @@ class COLORS(Enum):
LIGHT_YELLOW = "FFFFFF99" LIGHT_YELLOW = "FFFFFF99"
# Un style est enregistré comme un dictionnaire qui précise la valeur d'un attribut dans la liste suivante: # Un style est enregistré comme un dictionnaire avec des attributs dans la liste suivante:
# font, border, number_format, fill,... # font, border, number_format, fill,...
# (cf https://openpyxl.readthedocs.io/en/stable/styles.html#working-with-styles) # (cf https://openpyxl.readthedocs.io/en/stable/styles.html#working-with-styles)
def xldate_as_datetime(xldate, datemode=0): def xldate_as_datetime(xldate):
"""Conversion d'une date Excel en datetime python """Conversion d'une date Excel en datetime python
Deux formats de chaîne acceptés: Deux formats de chaîne acceptés:
* JJ/MM/YYYY (chaîne naïve) * JJ/MM/YYYY (chaîne naïve)
@ -187,8 +187,8 @@ def excel_make_style(
class ScoExcelSheet: class ScoExcelSheet:
"""Représente une feuille qui peut être indépendante ou intégrée dans un ScoExcelBook. """Représente une feuille qui peut être indépendante ou intégrée dans un ScoExcelBook.
En application des directives de la bibliothèque sur l'écriture optimisée, l'ordre des opérations En application des directives de la bibliothèque sur l'écriture optimisée,
est imposé: l'ordre des opérations est imposé:
* instructions globales (largeur/maquage des colonnes et ligne, ...) * instructions globales (largeur/maquage des colonnes et ligne, ...)
* construction et ajout des cellules et ligne selon le sens de lecture (occidental) * construction et ajout des cellules et ligne selon le sens de lecture (occidental)
ligne de haut en bas et cellules de gauche à droite (i.e. A1, A2, .. B1, B2, ..) ligne de haut en bas et cellules de gauche à droite (i.e. A1, A2, .. B1, B2, ..)
@ -199,7 +199,7 @@ class ScoExcelSheet:
"""Création de la feuille. sheet_name """Création de la feuille. sheet_name
-- le nom de la feuille default_style -- le nom de la feuille default_style
-- le style par défaut des cellules ws -- le style par défaut des cellules ws
-- None si la feuille est autonome (dans ce cas elle crée son propre wb), sinon c'est la worksheet -- None si la feuille est autonome (elle crée son propre wb), sinon c'est la worksheet
créée par le workbook propriétaire un workbook est créé et associé à cette feuille. créée par le workbook propriétaire un workbook est créé et associé à cette feuille.
""" """
# Le nom de la feuille ne peut faire plus de 31 caractères. # Le nom de la feuille ne peut faire plus de 31 caractères.
@ -228,7 +228,8 @@ class ScoExcelSheet:
fill=None, fill=None,
number_format=None, number_format=None,
font=None, font=None,
): ) -> dict:
"création d'un dict"
style = {} style = {}
if font is not None: if font is not None:
style["font"] = font style["font"] = font
@ -393,7 +394,7 @@ class ScoExcelSheet:
if isinstance(value, datetime.date): if isinstance(value, datetime.date):
cell.data_type = "d" cell.data_type = "d"
cell.number_format = FORMAT_DATE_DDMMYY cell.number_format = FORMAT_DATE_DDMMYY
elif isinstance(value, int) or isinstance(value, float): elif isinstance(value, (int, float)):
cell.data_type = "n" cell.data_type = "n"
else: else:
cell.data_type = "s" cell.data_type = "s"
@ -432,10 +433,11 @@ class ScoExcelSheet:
Ce flux pourra ensuite être repris dans send_excel_file (classeur mono feille) Ce flux pourra ensuite être repris dans send_excel_file (classeur mono feille)
ou pour la génération d'un classeur multi-feuilles ou pour la génération d'un classeur multi-feuilles
""" """
for row in self.column_dimensions.keys(): for k, v in self.column_dimensions.items():
self.ws.column_dimensions[row] = self.column_dimensions[row] self.ws.column_dimensions[k] = v
for row in self.row_dimensions.keys():
self.ws.row_dimensions[row] = self.row_dimensions[row] for k, v in self.row_dimensions.items():
self.ws.row_dimensions[k] = self.row_dimensions[v]
for row in self.rows: for row in self.rows:
self.ws.append(row) self.ws.append(row)
@ -529,17 +531,6 @@ def excel_file_to_list(filename):
) from exc ) from exc
def excel_workbook_to_list(filename):
try:
return _excel_workbook_to_list(filename)
except Exception as exc:
raise ScoValueError(
"""Le fichier xlsx attendu n'est pas lisible !
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
"""
) from exc
def _open_workbook(filelike, dump_debug=False) -> Workbook: def _open_workbook(filelike, dump_debug=False) -> Workbook:
"""Open document. """Open document.
On error, if dump-debug is True, dump data in /tmp for debugging purpose On error, if dump-debug is True, dump data in /tmp for debugging purpose
@ -559,7 +550,7 @@ def _open_workbook(filelike, dump_debug=False) -> Workbook:
return workbook return workbook
def _excel_to_list(filelike): def _excel_to_list(filelike) -> tuple[list, list[list]]:
"""returns list of list""" """returns list of list"""
workbook = _open_workbook(filelike) workbook = _open_workbook(filelike)
diag = [] # liste de chaines pour former message d'erreur diag = [] # liste de chaines pour former message d'erreur
@ -576,7 +567,7 @@ def _excel_to_list(filelike):
return diag, matrix return diag, matrix
def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list]: def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list[list]]:
"""read a spreadsheet sheet, and returns: """read a spreadsheet sheet, and returns:
- diag : a list of strings (error messages aimed at helping the user) - diag : a list of strings (error messages aimed at helping the user)
- a list of lists: the spreadsheet cells - a list of lists: the spreadsheet cells
@ -609,14 +600,21 @@ def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list]
return diag, matrix return diag, matrix
def _excel_workbook_to_list(filelike): def excel_workbook_to_list(filelike):
"""Lit un classeur (workbook): chaque feuille est lue """Lit un classeur (workbook): chaque feuille est lue
et est convertie en une liste de listes. et est convertie en une liste de listes.
Returns: Returns:
- diag : a list of strings (error messages aimed at helping the user) - diag : a list of strings (error messages aimed at helping the user)
- a list of lists: the spreadsheet cells - a list of lists: the spreadsheet cells
""" """
try:
workbook = _open_workbook(filelike) workbook = _open_workbook(filelike)
except Exception as exc:
raise ScoValueError(
"""Le fichier xlsx attendu n'est pas lisible !
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
"""
) from exc
diag = [] # liste de chaines pour former message d'erreur diag = [] # liste de chaines pour former message d'erreur
if len(workbook.sheetnames) < 1: if len(workbook.sheetnames) < 1:
diag.append("Aucune feuille trouvée dans le classeur !") diag.append("Aucune feuille trouvée dans le classeur !")
@ -631,6 +629,7 @@ def _excel_workbook_to_list(filelike):
return diag, matrix_list return diag, matrix_list
# TODO déplacer dans un autre fichier
def excel_feuille_listeappel( def excel_feuille_listeappel(
sem, sem,
groupname, groupname,

View File

@ -23,6 +23,21 @@
############################################################################## ##############################################################################
"""Fichier excel de saisie des notes """Fichier excel de saisie des notes
## Notes d'une évaluation
saisie_notes_tableur (formulaire)
-> feuille_saisie_notes (génération de l'excel)
-> do_evaluations_upload_xls
## Notes d'un semestre
formsemestre_import_notes (formulaire, import_notes.j2)
-> feuille_import_notes (génération de l'excel)
-> formsemestre_import_notes
""" """
from collections import defaultdict from collections import defaultdict
from typing import AnyStr from typing import AnyStr
@ -30,13 +45,14 @@ from typing import AnyStr
from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side
from openpyxl.styles.numbers import FORMAT_GENERAL from openpyxl.styles.numbers import FORMAT_GENERAL
from flask import g, request, url_for from flask import g, render_template, request, url_for
from flask_login import current_user from flask_login import current_user
from app.models import Evaluation, FormSemestre, Identite, Module, ScolarNews from app.models import Evaluation, FormSemestre, Identite, ScolarNews
from app.scodoc.sco_excel import COLORS, ScoExcelSheet from app.scodoc.sco_excel import COLORS, ScoExcelSheet
from app.scodoc import ( from app.scodoc import (
html_sco_header, html_sco_header,
sco_cache,
sco_evaluations, sco_evaluations,
sco_evaluation_db, sco_evaluation_db,
sco_excel, sco_excel,
@ -48,6 +64,7 @@ from app.scodoc import (
from app.scodoc.sco_exceptions import AccessDenied, InvalidNoteValue from app.scodoc.sco_exceptions import AccessDenied, InvalidNoteValue
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.TrivialFormulator import TrivialFormulator
from app.views import ScoData
FONT_NAME = "Arial" FONT_NAME = "Arial"
@ -180,7 +197,7 @@ def _insert_line_titles(
ws.append_row(cells) ws.append_row(cells)
# Calcul largeur colonnes (actuellement pour feuille import multi seulement) # Calcul largeur colonnes (actuellement pour feuille import multi seulement)
# Le facteur prend en compte la tailel du font (14) # Le facteur prend en compte la taille du font (14)
font_size_factor = 1.25 font_size_factor = 1.25
column_widths = { column_widths = {
ScoExcelSheet.i2col(idx): (len(str(cell.value)) + 2.0) * font_size_factor ScoExcelSheet.i2col(idx): (len(str(cell.value)) + 2.0) * font_size_factor
@ -525,36 +542,113 @@ def generate_excel_import_notes(
return ws.generate(column_widths=column_widths) return ws.generate(column_widths=column_widths)
def do_evaluation_upload_xls() -> tuple[bool, str]: def do_evaluations_upload_xls(
notefile,
comment: str = "",
evaluation: Evaluation | None = None,
formsemestre: FormSemestre | None = None,
) -> tuple[bool, str]:
""" """
Soumission d'un fichier XLS (evaluation_id, notefile) Soumission d'un fichier XLS (evaluation_id, notefile)
soit dans le formsemestre (import multi-eval)
soit dans une seule évaluation
return: return:
ok: bool ok: bool
msg: message diagonistic à affciher msg: message diagnostic à affciher
""" """
args = scu.get_request_args() diag, rows = sco_excel.excel_file_to_list(notefile)
comment = args["comment"]
evaluation = Evaluation.get_evaluation(args["evaluation_id"])
# Check access (admin, respformation, responsable_id, ens)
if not evaluation.moduleimpl.can_edit_notes(current_user):
raise AccessDenied(f"Modification des notes impossible pour {current_user}")
#
diag, rows = sco_excel.excel_file_to_list(args["notefile"])
try: try:
if not rows: if not rows:
raise InvalidNoteValue() raise InvalidNoteValue()
# Lecture des évaluations ids
row_title_idx, evaluations, evaluations_col_idx = _get_sheet_evaluations( row_title_idx, evaluations, evaluations_col_idx = _get_sheet_evaluations(
rows, evaluation=evaluation, diag=diag rows, evaluation=evaluation, formsemestre=formsemestre, diag=diag
) )
# --- get notes -> list (etudid, value)
# ignore toutes les lignes ne commençant pas par ! # Vérification des permissions (admin, resp. formation, responsable_id, ens)
notes_by_eval = defaultdict( for e in evaluations:
list if not e.moduleimpl.can_edit_notes(current_user):
) # { evaluation_id : [ (etudid, note_value), ... ] } raise AccessDenied(
ni = row_title_idx + 1 f"""Modification des notes
for row in rows[row_title_idx + 1 :]: dans le module {e.moduleimpl.module.code}
impossible pour {current_user}"""
)
# Lecture des notes
notes_by_eval = _read_notes_from_rows(
rows, diag, evaluations, evaluations_col_idx, start=row_title_idx + 1
)
# -- Enregistre les notes de chaque évaluation
with sco_cache.DeferredSemCacheManager():
messages_by_eval, etudids_with_decisions = _record_notes_evaluations(
evaluations, notes_by_eval, comment, diag, rows=rows
)
# -- News
if len(evaluations) > 1:
modules_str = ", ".join(
[evaluation.moduleimpl.module.code for evaluation in evaluations]
)
status_url = url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)
obj_id = formsemestre.id
else:
modules_str = (
evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code
)
status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=evaluation.moduleimpl_id,
)
obj_id = evaluation.moduleimpl_id
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=obj_id,
text=f"""Chargement notes dans <a href="{status_url}">{modules_str}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes
)
msg = "<div>" + "\n".join(messages_by_eval.values()) + "</div>"
if etudids_with_decisions:
msg = (
"""<p class="warning"><b>Important:</b>
Il y avait déjà des décisions de jury
enregistrées, qui sont à revoir suite à cette modification !
</p>
"""
+ msg
)
return True, msg
except InvalidNoteValue:
if diag:
msg = (
'<ul class="tf-msg"><li class="tf_msg">'
+ '</li><li class="tf_msg">'.join(diag)
+ "</li></ul>"
)
else:
msg = '<ul class="tf-msg"><li class="tf_msg">Une erreur est survenue</li></ul>'
return False, msg + "<p>(pas de notes modifiées)</p>"
def _read_notes_from_rows(
rows: list[list], diag, evaluations, evaluations_col_idx, start=0
):
"""--- get notes -> list (etudid, value)
ignore toutes les lignes ne commençant pas par '!'
"""
# { evaluation_id : [ (etudid, note_value), ... ] }
notes_by_eval = defaultdict(list)
ni = start
for row in rows[start:]:
if row: if row:
cell0 = row[0].strip() cell0 = row[0].strip()
if cell0 and cell0[0] == "!": if cell0 and cell0[0] == "!":
@ -575,12 +669,20 @@ def do_evaluation_upload_xls() -> tuple[bool, str]:
) )
ni += 1 ni += 1
return notes_by_eval
def _record_notes_evaluations(
evaluations, notes_by_eval, comment, diag, rows: list[list[str]] | None = None
) -> tuple[dict[int, str], set[int]]:
"""Enregistre les notes dans les évaluations
Return: messages_by_eval, etudids_with_decisions
"""
# -- Check values de chaque évaluation # -- Check values de chaque évaluation
valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval = ( valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval = (
_check_notes_evaluations(evaluations, notes_by_eval, diag) _check_notes_evaluations(evaluations, notes_by_eval, diag, rows=rows)
) )
# -- Enregistre les notes de chaque évaluation
messages_by_eval: dict[int, str] = {} messages_by_eval: dict[int, str] = {}
etudids_with_decisions = set() etudids_with_decisions = set()
for evaluation in evaluations: for evaluation in evaluations:
@ -589,17 +691,18 @@ def do_evaluation_upload_xls() -> tuple[bool, str]:
continue continue
etudids_changed, nb_suppress, etudids_with_decisions_eval, messages = ( etudids_changed, nb_suppress, etudids_with_decisions_eval, messages = (
sco_saisie_notes.notes_add( sco_saisie_notes.notes_add(
current_user, current_user, evaluation.id, valid_notes, comment
evaluation.id,
valid_notes_by_eval[evaluation.id],
comment,
) )
) )
etudids_with_decisions |= set(etudids_with_decisions_eval) etudids_with_decisions |= set(etudids_with_decisions_eval)
msg = f"""<div class="diag-evaluation"> msg = f"""<div class="diag-evaluation">
<ul> <ul>
<li><div>Module {evaluation.moduleimpl.module.code} : <li><div class="{'diag-change' if etudids_changed else 'diag-nochange'}">
Module {evaluation.moduleimpl.module.code} :
évaluation {evaluation.description} {evaluation.descr_date()} évaluation {evaluation.description} {evaluation.descr_date()}
"""
msg += (
f"""
</div> </div>
<div> <div>
{len(etudids_changed)} notes changées {len(etudids_changed)} notes changées
@ -607,9 +710,11 @@ def do_evaluation_upload_xls() -> tuple[bool, str]:
{len(etudids_absents_by_eval[evaluation.id])} absents, {len(etudids_absents_by_eval[evaluation.id])} absents,
{nb_suppress} note supprimées) {nb_suppress} note supprimées)
</div> </div>
</li>
</ul>
""" """
if etudids_changed
else " : pas de changement</div>"
)
msg += "</li></ul>"
if messages: if messages:
msg += f"""<div class="warning">Attention&nbsp;: msg += f"""<div class="warning">Attention&nbsp;:
<ul> <ul>
@ -621,46 +726,14 @@ def do_evaluation_upload_xls() -> tuple[bool, str]:
</div>""" </div>"""
msg += """</div>""" msg += """</div>"""
messages_by_eval[evaluation.id] = msg messages_by_eval[evaluation.id] = msg
return messages_by_eval, etudids_with_decisions
# -- News
module: Module = evaluation.moduleimpl.module
status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=evaluation.moduleimpl_id,
_external=True,
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
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
)
if etudids_with_decisions:
msg += """<p class="warning"><b>Important:</b> il y avait déjà des décisions de jury
enregistrées, qui sont à revoir suite à cette modification !</p>
"""
return True, msg
except InvalidNoteValue:
if diag:
msg = (
'<ul class="tf-msg"><li class="tf_msg">'
+ '</li><li class="tf_msg">'.join(diag)
+ "</li></ul>"
)
else:
msg = '<ul class="tf-msg"><li class="tf_msg">Une erreur est survenue</li></ul>'
return False, msg + "<p>(pas de notes modifiées)</p>"
def _check_notes_evaluations( def _check_notes_evaluations(
evaluations: list[Evaluation], evaluations: list[Evaluation],
notes_by_eval: dict[int, list[tuple[int, str]]], notes_by_eval: dict[int, list[tuple[int, str]]],
diag: list[str], diag: list[str],
rows: list[list[str]] | None = None,
) -> tuple[dict[int, list[tuple[int, str]]], list[int], list[int]]: ) -> tuple[dict[int, list[tuple[int, str]]], list[int], list[int]]:
"""Vérifie que les notes pour ces évaluations sont valides. """Vérifie que les notes pour ces évaluations sont valides.
Raise InvalidNoteValue et rempli diag si ce n'est pas le cas. Raise InvalidNoteValue et rempli diag si ce n'est pas le cas.
@ -678,29 +751,40 @@ def _check_notes_evaluations(
etudids_non_inscrits, etudids_non_inscrits,
) = sco_saisie_notes.check_notes(notes_by_eval[evaluation.id], evaluation) ) = sco_saisie_notes.check_notes(notes_by_eval[evaluation.id], evaluation)
if invalids: if invalids:
diag.append( diag.append(f"Erreur: la feuille contient {len(invalids)} notes invalides")
f"Erreur: la feuille contient {len(invalids)} notes invalides</p>" msg = f"""Notes invalides dans {
) evaluation.moduleimpl.module.code} {evaluation.description} pour : """
if len(invalids) < 25: if len(invalids) < 25:
etudsnames = [ etudsnames = [
Identite.get_etud(etudid).nom_prenom() for etudid in invalids Identite.get_etud(etudid).nom_prenom() for etudid in invalids
] ]
diag.append("Notes invalides pour: " + ", ".join(etudsnames)) msg += ", ".join(etudsnames)
else: else:
diag.append("Notes invalides pour plus de 25 étudiants") msg += "plus de 25 étudiants"
diag.append(msg)
raise InvalidNoteValue() raise InvalidNoteValue()
if etudids_non_inscrits: if etudids_non_inscrits:
msg = ""
if len(etudids_non_inscrits) < 25:
# retrouve numéro ligne et données invalides dans fichier
for etudid in etudids_non_inscrits:
try:
index = [row[0] for row in rows].index(f"!{etudid}")
except ValueError:
index = None
msg += f"""<li>Ligne {index+1}:
{rows[index][1]} {rows[index][2]} (id={rows[index][0]})
</li>"""
else:
msg += "<li>sur plus de 25 lignes</li>"
diag.append( diag.append(
f"""Erreur: la feuille contient {len(etudids_non_inscrits) f"""Erreur: la feuille contient {len(etudids_non_inscrits)
} étudiants non inscrits</p>""" } étudiants inexistants ou non inscrits à l'évaluation
{evaluation.moduleimpl.module.code}
{evaluation.description}
<ul>{msg}</ul>
"""
) )
if len(etudids_non_inscrits) < 25:
diag.append(
"etudid invalides (inexistants ou non inscrits): "
+ ", ".join(str(etudid) for etudid in etudids_non_inscrits)
)
else:
diag.append("etudid invalides sur plus de 25 lignes")
raise InvalidNoteValue() raise InvalidNoteValue()
return valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval return valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval
@ -724,8 +808,61 @@ def _read_notes_evaluations(
notes_by_eval[evaluation.id].append((etud.id, val)) notes_by_eval[evaluation.id].append((etud.id, val))
def _xls_search_sheet_code(
rows: list[list[str]], diag: list[str] = None
) -> tuple[int, int | dict[int, int]]:
"""Cherche dans la feuille (liste de listes de chaines)
la ligne identifiant la ou les évaluations.
Si MULTIEVAL (import de plusieurs), renvoie
- l'indice de la ligne des TITRES
- un dict evaluations_col_idx: { evaluation_id : indice de sa colonne dans la feuille }
Si une seule éval (chargement dans une évaluation)
- l'indice de la ligne des TITRES
- l'evaluation_id indiqué dans la feuille
"""
for i, row in enumerate(rows):
if not row:
diag.append("Erreur: feuille invalide (ligne vide ?)")
raise InvalidNoteValue()
eval_code = row[0].strip()
# -- eval code: first cell in 1st column beginning by "!"
if eval_code.startswith("!"): # code évaluation trouvé
try:
sheet_eval_id = int(eval_code[1:])
except ValueError as exc:
diag.append("Erreur: feuille invalide ! (code évaluation invalide)")
raise InvalidNoteValue() from exc
return i, sheet_eval_id
# -- Muti-évaluation: les codes sont au dessus des titres
elif eval_code == "MULTIEVAL": # feuille import multi-eval
# cherche les ids des évaluations sur la même ligne
try:
evaluation_ids = [int(x) for x in row[4:]]
except ValueError as exc:
diag.append(
f"Erreur: feuille invalide ! (code évaluation invalide sur ligne {i+1})"
)
raise InvalidNoteValue() from exc
evaluations_col_idx = {
evaluation_id: j
for (j, evaluation_id) in enumerate(evaluation_ids, start=4)
}
return (
i + 1, # i+1 car MULTIEVAL sur la ligne précédent les titres
evaluations_col_idx,
)
diag.append("Erreur: feuille invalide ! (pas de ligne code évaluation)")
raise InvalidNoteValue()
def _get_sheet_evaluations( def _get_sheet_evaluations(
rows: list[list[str]], evaluation: Evaluation | None = None, diag: list[str] = None rows: list[list[str]],
evaluation: Evaluation | None = None,
formsemestre: FormSemestre | None = None,
diag: list[str] = None,
) -> tuple[int, list[Evaluation], dict[int, int]]: ) -> tuple[int, list[Evaluation], dict[int, int]]:
""" """
rows: les valeurs (str) des cellules de la feuille rows: les valeurs (str) des cellules de la feuille
@ -735,35 +872,38 @@ def _get_sheet_evaluations(
formsemestre ou evaluation doivent être indiqués. formsemestre ou evaluation doivent être indiqués.
Résultat: Résultat:
row_title_idx: l'indice (à partir de 0) de la ligne titre (après laquelle commencent les notes) row_title_idx: l'indice (à partir de 0) de la ligne TITRE (après laquelle commencent les notes)
evaluations: liste des évaluations à remplir evaluations: liste des évaluations à remplir
evaluations_col_idx: { evaluation_id : indice de sa colonne dans la feuille } evaluations_col_idx: { evaluation_id : indice de sa colonne dans la feuille }
""" """
# -- search eval code: first cell in 1st column beginning by "!"
eval_code = None
for i, row in enumerate(rows):
if not row:
diag.append("Erreur: format invalide (ligne vide ?)")
raise InvalidNoteValue()
eval_code = row[0].strip()
if eval_code.startswith("!"):
break
if not eval_code:
diag.append("Erreur: format invalide ! (pas de ligne evaluation_id)")
raise InvalidNoteValue()
try: i, r = _xls_search_sheet_code(rows, diag)
sheet_eval_id = int(eval_code[1:]) if isinstance(r, int): # mono-eval
except ValueError: sheet_eval_id = r
sheet_eval_id = None
if sheet_eval_id != evaluation.id: if sheet_eval_id != evaluation.id:
diag.append( diag.append(
f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{ f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{
sheet_eval_id or ('non trouvé')}' != '{evaluation.id}')""" sheet_eval_id or ('non trouvé')}' != '{evaluation.id}')"""
) )
raise InvalidNoteValue() raise InvalidNoteValue()
return i, [evaluation], {evaluation.id: 4} return i, [evaluation], {evaluation.id: 4}
if isinstance(r, dict): # multi-eval
evaluations = []
evaluations_col_idx = r
# Load and check evaluations
for evaluation_id in evaluations_col_idx:
evaluation = Evaluation.get_evaluation(evaluation_id, accept_none=True)
if evaluation is None:
diag.append(f"""Erreur: l'évaluation {evaluation_id} n'existe pas""")
raise InvalidNoteValue()
if evaluation.moduleimpl.formsemestre_id != formsemestre.id:
diag.append(
f"""Erreur: l'évaluation {evaluation_id} n'existe pas dans ce semestre"""
)
raise InvalidNoteValue()
evaluations.append(evaluation)
return i, evaluations, evaluations_col_idx
raise ValueError("_get_sheet_evaluations")
def saisie_notes_tableur(evaluation_id: int, group_ids=()): def saisie_notes_tableur(evaluation_id: int, group_ids=()):
@ -840,7 +980,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
<span class="titredivsaisienote">Étape 2 : chargement d'un fichier de notes</span>""" # ' <span class="titredivsaisienote">Étape 2 : chargement d'un fichier de notes</span>""" # '
) )
nf = TrivialFormulator( tf = TrivialFormulator(
request.base_url, request.base_url,
scu.get_request_args(), scu.get_request_args(),
( (
@ -861,23 +1001,27 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
formid="notesfile", formid="notesfile",
submitlabel="Télécharger", submitlabel="Télécharger",
) )
if nf[0] == 0: if tf[0] == 0:
H.append( H.append(
"""<p>Le fichier doit être un fichier tableur obtenu via """<p>Le fichier doit être un fichier tableur obtenu via
l'étape 1 ci-dessus, puis complété et enregistré au format Excel. l'étape 1 ci-dessus, puis complété et enregistré au format Excel.
</p>""" </p>"""
) )
H.append(nf[1]) H.append(tf[1])
elif nf[0] == -1: elif tf[0] == -1:
H.append("<p>Annulation</p>") H.append("<p>Annulation</p>")
elif nf[0] == 1: elif tf[0] == 1:
updiag = do_evaluation_upload_xls() args = scu.get_request_args()
if updiag[0]: evaluation = Evaluation.get_evaluation(args["evaluation_id"])
ok, diagnostic_msg = do_evaluations_upload_xls(
args["notefile"], evaluation=evaluation, comment=args["comment"]
)
if ok:
H.append( H.append(
f""" f"""
<div class="notes-chargees"> <div class="notes-chargees">
<div><b>Notes chargées !</b></div> <div><b>Notes chargées !</b></div>
{updiag[1]} {diagnostic_msg}
</div> </div>
<a class="stdlink" href="{ <a class="stdlink" href="{
url_for("notes.moduleimpl_status", url_for("notes.moduleimpl_status",
@ -898,7 +1042,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
H.append( H.append(
f""" f"""
<p class="redboldtext">Notes non chargées !</p> <p class="redboldtext">Notes non chargées !</p>
{updiag[1]} {diagnostic_msg}
<p><a class="stdlink" href="{url_for("notes.saisie_notes_tableur", <p><a class="stdlink" href="{url_for("notes.saisie_notes_tableur",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id) scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}"> }">
@ -955,7 +1099,8 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
Remarques: Remarques:
<ul> <ul>
<li>le fichier Excel peut être incomplet: on peut ne saisir que quelques notes <li>le fichier Excel peut être incomplet: on peut ne saisir que quelques notes
et répéter l'opération (en téléchargeant un nouveau fichier) plus tard; et répéter l'opération plus tard (en téléchargeant un nouveau fichier ou en
passant par le formulaire de saisie);
</li> </li>
<li>seules les valeurs des notes modifiées sont prises en compte; <li>seules les valeurs des notes modifiées sont prises en compte;
</li> </li>
@ -977,3 +1122,18 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
) )
H.append(html_sco_header.sco_footer()) H.append(html_sco_header.sco_footer())
return "\n".join(H) return "\n".join(H)
def formsemestre_import_notes(formsemestre: FormSemestre, notefile, comment: str):
"""Importation de notes dans plusieurs évaluations du semestre"""
ok, diagnostic_msg = do_evaluations_upload_xls(
notefile, formsemestre=formsemestre, comment=comment
)
return render_template(
"formsemestre/import_notes_after.j2",
comment=comment,
ok=ok,
diagnostic_msg=diagnostic_msg,
sco=ScoData(formsemestre=formsemestre),
title="Importation des notes",
)

View File

@ -156,6 +156,11 @@ def check_notes(
for etudid, note in notes: for etudid, note in notes:
if etudid not in etudids_inscrits_mod: 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) etudids_non_inscrits.append(etudid)
continue continue
try: try:
@ -388,6 +393,27 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer() 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( def notes_add(
user: User, user: User,
evaluation_id: int, evaluation_id: int,
@ -424,22 +450,8 @@ def notes_add(
for etudid, value in notes: for etudid, value in notes:
if check_inscription: if check_inscription:
msg_err, msg_warn = "", "" _check_inscription(etudid, etudids_inscrits_sem, etudids_inscrits_mod)
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"
elif etudid not in etudids_actifs:
# DEM ou DEF
msg_warn = "démissionnaire ou défaillant (note enregistrée)"
if msg_err or msg_warn:
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
msg = f"étudiant {etud.nomprenom if etud else etudid} {msg_err or msg_warn}"
if msg_err:
log(f"notes_add: {etudid} non inscrit ou DEM/DEF: aborting")
raise NoteProcessError(msg)
if msg_warn:
messages.append(msg)
if (value is not None) and not isinstance(value, float): if (value is not None) and not isinstance(value, float):
log(f"notes_add: {etudid} valeur de note invalide ({value}): aborting") log(f"notes_add: {etudid} valeur de note invalide ({value}): aborting")
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
@ -470,14 +482,26 @@ def notes_add(
date=now, date=now,
do_it=do_it, do_it=do_it,
) )
if suppressed: if suppressed:
nb_suppress += 1 nb_suppress += 1
if changed: if changed:
etudids_changed.append(etudid) 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): if res.etud_has_decision(etudid, include_rcues=False):
etudids_with_decision.append(etudid) etudids_with_decision.append(etudid)
except NotImplementedError as exc: # XXX
except Exception as exc:
log("*** exception in notes_add") log("*** exception in notes_add")
if do_it: if do_it:
cnx.rollback() # abort cnx.rollback() # abort
@ -485,10 +509,12 @@ def notes_add(
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)
raise ScoException from exc raise ScoException from exc
if do_it: if do_it:
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 etudids_changed, nb_suppress, etudids_with_decision, messages return etudids_changed, nb_suppress, etudids_with_decision, messages

View File

@ -72,10 +72,23 @@ div.scobox.explanation {
background-color: var(--sco-color-background); background-color: var(--sco-color-background);
} }
div.scobox.success div.scobox-title {
color: white;
background-color: darkgreen;
}
div.scobox.failure div.scobox-title {
color: white;
background-color: #c50000;
}
div.scobox div.scobox-title { div.scobox div.scobox-title {
font-size: 120%; font-size: 120%;
font-weight: bold; font-weight: bold;
margin-bottom: 8px; margin-bottom: 8px;
padding-left: 8px;
padding-top: 4px;
padding-bottom: 4px;
} }
div.scobox-buttons { div.scobox-buttons {

View File

@ -4,7 +4,9 @@
{% block styles %} {% block styles %}
{{super()}} {{super()}}
<style> <style>
div.vspace {
margin-top: 24px;
}
</style> </style>
{% endblock %} {% endblock %}
@ -35,7 +37,7 @@ Cette page permet d'importer des notes dans tout ou partie des évaluations du s
</ul> </ul>
</div> </div>
<div class="help" style="margin-top: 24px;">Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le <div class="help vspace">Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le
ci-dessous. ci-dessous.
Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est jamais montré aux étudiants. Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est jamais montré aux étudiants.
</div> </div>
@ -47,4 +49,32 @@ Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est
</div> </div>
<div class="help vspace">
À l'<b>étape 2</b>, indiquer le fichier Excel
<em>téléchargé à l'étape 1</em> et dans lequel on a saisi des notes.
<div class="vspace">
<b>Remarques :</b>
<ul>
<li>Le fichier Excel <em>doit impérativement être celui chargé à
l'étape 1 pour ce semestre</em>. Il n'est pas possible d'utiliser
une liste d'appel ou autre document Excel téléchargé d'une autre page.
</li>
<li>Ne pas supprimer les lignes et colonnes cachées, qui
contiennent des codes. Le fichier exporté contient toutles les
évaluations du semestre, vous pouvez au besoin supprimer certaines
colonnes d'évaluations.
</li>
<li>Le fichier peut être incomplet: on
peut ne saisir que quelques notes et répéter l'opération plus tard (en
téléchargeant un nouveau fichier ou en passant par le formulaire de
saisie).
</li>
<li>Seules les valeurs des notes modifiées sont prises en
compte.
</li>
</ul>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,54 @@
{% extends "sco_page.j2" %}
{% import 'wtf.j2' as wtf %}
{% block styles %}
{{super()}}
<style>
.import-diag ul.tf-msg {
padding-top: 8px;
padding-bottom: 8px;
}
.diag-change {
font-weight: bold;
}
.diag-nochange {
color: gray;
}
</style>
{% endblock %}
{% block app_content %}
<h2>Import de notes dans les évaluations du semestre</h2>
<div class="scobox {{ 'success' if ok else 'failure' }}">
<div class="scobox-title">
{% if ok %}
Notes importées avec succès
{% else %}
Erreur: aucune note chargée
{% endif %}
</div>
<div class="import-diag">
{{ diagnostic_msg | safe }}
</div>
</div>
<div class="scobox">
<ul>
<li><a class="stdlink"
href="{{url_for('notes.formsemestre_recapcomplet',
scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id,
tabformat='evals')}}">
Tableau de <em>toutes</em> les notes du semestre
</a>
</li>
<li><a class="stdlink"
href="{{url_for('notes.formsemestre_import_notes',
scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id)}}">
Importer d'autres notes
</a>
</li>
</div>
{% endblock %}

View File

@ -1870,15 +1870,17 @@ def formsemestre_import_notes(formsemestre_id: int):
# Handle file upload and form processing # Handle file upload and form processing
notefile = form.notefile.data notefile = form.notefile.data
comment = form.comment.data comment = form.comment.data
# Save the file and process form data here #
raise ScoValueError("unimplemented") return sco_saisie_excel.formsemestre_import_notes(
return redirect(url_for("index")) formsemestre, notefile, comment
)
return render_template( return render_template(
"formsemestre/import_notes.j2", "formsemestre/import_notes.j2",
evaluations=formsemestre.get_evaluations(), evaluations=formsemestre.get_evaluations(),
form=form, form=form,
formsemestre=formsemestre, formsemestre=formsemestre,
title="Importation des notes",
sco=ScoData(formsemestre=formsemestre), sco=ScoData(formsemestre=formsemestre),
) )

View File

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