From 9b825c0fb10a1739da75f7a7bf2dcda0ec8380e4 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet
Date: Sun, 14 Jul 2024 22:20:37 +0200
Subject: [PATCH] =?UTF-8?q?Saisie=20notes=20multi-=C3=A9valuations.=20Clos?=
=?UTF-8?q?es=20#942.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/models/evaluations.py | 10 +-
app/scodoc/sco_excel.py | 51 +-
app/scodoc/sco_saisie_excel.py | 450 ++++++++++++------
app/scodoc/sco_saisie_notes.py | 62 ++-
app/static/css/scodoc.css | 13 +
app/templates/formsemestre/import_notes.j2 | 34 +-
.../formsemestre/import_notes_after.j2 | 54 +++
app/views/notes.py | 8 +-
sco_version.py | 2 +-
9 files changed, 486 insertions(+), 198 deletions(-)
create mode 100644 app/templates/formsemestre/import_notes_after.j2
diff --git a/app/models/evaluations.py b/app/models/evaluations.py
index 4af3ab84..d2607f0d 100644
--- a/app/models/evaluations.py
+++ b/app/models/evaluations.py
@@ -267,10 +267,12 @@ class Evaluation(models.ScoDocModel):
@classmethod
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 ou 404, cherche uniquement dans le département spécifié ou le courant."""
- from app.models import FormSemestre, ModuleImpl
+ """Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant.
+ 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):
try:
@@ -282,6 +284,8 @@ class Evaluation(models.ScoDocModel):
query = cls.query.filter_by(id=evaluation_id)
if dept_id is not None:
query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id)
+ if accept_none:
+ return query.first()
return query.first_or_404()
@classmethod
diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py
index f82a04f9..da605093 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -60,12 +60,12 @@ class COLORS(Enum):
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,...
# (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
Deux formats de chaîne acceptés:
* JJ/MM/YYYY (chaîne naïve)
@@ -187,8 +187,8 @@ def excel_make_style(
class ScoExcelSheet:
"""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
- est imposé:
+ En application des directives de la bibliothèque sur l'écriture optimisée,
+ l'ordre des opérations est imposé:
* instructions globales (largeur/maquage des colonnes et ligne, ...)
* 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, ..)
@@ -199,7 +199,7 @@ class ScoExcelSheet:
"""Création de la feuille. sheet_name
-- le nom de la feuille default_style
-- 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.
"""
# Le nom de la feuille ne peut faire plus de 31 caractères.
@@ -228,7 +228,8 @@ class ScoExcelSheet:
fill=None,
number_format=None,
font=None,
- ):
+ ) -> dict:
+ "création d'un dict"
style = {}
if font is not None:
style["font"] = font
@@ -393,7 +394,7 @@ class ScoExcelSheet:
if isinstance(value, datetime.date):
cell.data_type = "d"
cell.number_format = FORMAT_DATE_DDMMYY
- elif isinstance(value, int) or isinstance(value, float):
+ elif isinstance(value, (int, float)):
cell.data_type = "n"
else:
cell.data_type = "s"
@@ -432,10 +433,11 @@ class ScoExcelSheet:
Ce flux pourra ensuite être repris dans send_excel_file (classeur mono feille)
ou pour la génération d'un classeur multi-feuilles
"""
- for row in self.column_dimensions.keys():
- self.ws.column_dimensions[row] = self.column_dimensions[row]
- for row in self.row_dimensions.keys():
- self.ws.row_dimensions[row] = self.row_dimensions[row]
+ for k, v in self.column_dimensions.items():
+ self.ws.column_dimensions[k] = v
+
+ for k, v in self.row_dimensions.items():
+ self.ws.row_dimensions[k] = self.row_dimensions[v]
for row in self.rows:
self.ws.append(row)
@@ -529,17 +531,6 @@ def excel_file_to_list(filename):
) 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:
"""Open document.
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
-def _excel_to_list(filelike):
+def _excel_to_list(filelike) -> tuple[list, list[list]]:
"""returns list of list"""
workbook = _open_workbook(filelike)
diag = [] # liste de chaines pour former message d'erreur
@@ -576,7 +567,7 @@ def _excel_to_list(filelike):
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:
- diag : a list of strings (error messages aimed at helping the user)
- 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
-def _excel_workbook_to_list(filelike):
+def excel_workbook_to_list(filelike):
"""Lit un classeur (workbook): chaque feuille est lue
et est convertie en une liste de listes.
Returns:
- diag : a list of strings (error messages aimed at helping the user)
- a list of lists: the spreadsheet cells
"""
- workbook = _open_workbook(filelike)
+ try:
+ 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
if len(workbook.sheetnames) < 1:
diag.append("Aucune feuille trouvée dans le classeur !")
@@ -631,6 +629,7 @@ def _excel_workbook_to_list(filelike):
return diag, matrix_list
+# TODO déplacer dans un autre fichier
def excel_feuille_listeappel(
sem,
groupname,
diff --git a/app/scodoc/sco_saisie_excel.py b/app/scodoc/sco_saisie_excel.py
index 620111dc..1ef7e858 100644
--- a/app/scodoc/sco_saisie_excel.py
+++ b/app/scodoc/sco_saisie_excel.py
@@ -23,6 +23,21 @@
##############################################################################
"""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 typing import AnyStr
@@ -30,13 +45,14 @@ from typing import AnyStr
from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side
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 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 import (
html_sco_header,
+ sco_cache,
sco_evaluations,
sco_evaluation_db,
sco_excel,
@@ -48,6 +64,7 @@ from app.scodoc import (
from app.scodoc.sco_exceptions import AccessDenied, InvalidNoteValue
import app.scodoc.sco_utils as scu
from app.scodoc.TrivialFormulator import TrivialFormulator
+from app.views import ScoData
FONT_NAME = "Arial"
@@ -180,7 +197,7 @@ def _insert_line_titles(
ws.append_row(cells)
# 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
column_widths = {
ScoExcelSheet.i2col(idx): (len(str(cell.value)) + 2.0) * font_size_factor
@@ -525,124 +542,89 @@ def generate_excel_import_notes(
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)
+ soit dans le formsemestre (import multi-eval)
+ soit dans une seule évaluation
return:
ok: bool
- msg: message diagonistic à affciher
+ msg: message diagnostic à affciher
"""
- args = scu.get_request_args()
- 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"])
+ diag, rows = sco_excel.excel_file_to_list(notefile)
try:
if not rows:
raise InvalidNoteValue()
+ # Lecture des évaluations ids
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 !
- notes_by_eval = defaultdict(
- list
- ) # { evaluation_id : [ (etudid, note_value), ... ] }
- ni = row_title_idx + 1
- for row in rows[row_title_idx + 1 :]:
- if row:
- cell0 = row[0].strip()
- if cell0 and cell0[0] == "!":
- etudid = cell0[1:]
- # check etud
- etud = Identite.get_etud(etudid, accept_none=True)
- if not etud:
- diag.append(
- f"étudiant id invalide en ligne {ni+1}"
- ) # ligne excel à partir de 1
- else:
- _read_notes_evaluations(
- row,
- etud,
- evaluations,
- notes_by_eval,
- evaluations_col_idx,
- )
- ni += 1
- # -- Check values de chaque évaluation
- valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval = (
- _check_notes_evaluations(evaluations, notes_by_eval, diag)
+ # Vérification des permissions (admin, resp. formation, responsable_id, ens)
+ for e in evaluations:
+ if not e.moduleimpl.can_edit_notes(current_user):
+ raise AccessDenied(
+ f"""Modification des notes
+ 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
- messages_by_eval: dict[int, str] = {}
- etudids_with_decisions = set()
- for evaluation in evaluations:
- valid_notes = valid_notes_by_eval.get(evaluation.id)
- if not valid_notes:
- continue
- etudids_changed, nb_suppress, etudids_with_decisions_eval, messages = (
- sco_saisie_notes.notes_add(
- current_user,
- evaluation.id,
- valid_notes_by_eval[evaluation.id],
- comment,
- )
+ with sco_cache.DeferredSemCacheManager():
+ messages_by_eval, etudids_with_decisions = _record_notes_evaluations(
+ evaluations, notes_by_eval, comment, diag, rows=rows
)
- etudids_with_decisions |= set(etudids_with_decisions_eval)
- msg = f"""
-
- Module {evaluation.moduleimpl.module.code} :
- évaluation {evaluation.description} {evaluation.descr_date()}
-
-
- {len(etudids_changed)} notes changées
- ({len(etudids_without_notes_by_eval[evaluation.id])} sans notes,
- {len(etudids_absents_by_eval[evaluation.id])} absents,
- {nb_suppress} note supprimées)
-
-
-
- """
- if messages:
- msg += f"""
Attention :
-
- - {
- '
- '.join(messages)
- }
-
-
-
"""
- msg += """
"""
- messages_by_eval[evaluation.id] = msg
# -- 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,
- )
+ 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=evaluation.moduleimpl_id,
- text=f"""Chargement notes dans {
- module.titre or module.code}""",
+ obj=obj_id,
+ text=f"""Chargement notes dans {modules_str}""",
url=status_url,
max_frequency=30 * 60, # 30 minutes
)
+ msg = "" + "\n".join(messages_by_eval.values()) + "
"
if etudids_with_decisions:
- msg += """Important: il y avait déjà des décisions de jury
- enregistrées, qui sont à revoir suite à cette modification !
- """
+ msg = (
+ """Important:
+ Il y avait déjà des décisions de jury
+ enregistrées, qui sont à revoir suite à cette modification !
+
+ """
+ + msg
+ )
return True, msg
except InvalidNoteValue:
@@ -657,10 +639,101 @@ def do_evaluation_upload_xls() -> tuple[bool, str]:
return False, msg + "(pas de notes modifiées)
"
+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:
+ cell0 = row[0].strip()
+ if cell0 and cell0[0] == "!":
+ etudid = cell0[1:]
+ # check etud
+ etud = Identite.get_etud(etudid, accept_none=True)
+ if not etud:
+ diag.append(
+ f"étudiant id invalide en ligne {ni+1}"
+ ) # ligne excel à partir de 1
+ else:
+ _read_notes_evaluations(
+ row,
+ etud,
+ evaluations,
+ notes_by_eval,
+ evaluations_col_idx,
+ )
+ 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
+ valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval = (
+ _check_notes_evaluations(evaluations, notes_by_eval, diag, rows=rows)
+ )
+
+ messages_by_eval: dict[int, str] = {}
+ etudids_with_decisions = set()
+ for evaluation in evaluations:
+ valid_notes = valid_notes_by_eval.get(evaluation.id)
+ if not valid_notes:
+ continue
+ etudids_changed, nb_suppress, etudids_with_decisions_eval, messages = (
+ sco_saisie_notes.notes_add(
+ current_user, evaluation.id, valid_notes, comment
+ )
+ )
+ etudids_with_decisions |= set(etudids_with_decisions_eval)
+ msg = f"""
+
+
+ Module {evaluation.moduleimpl.module.code} :
+ évaluation {evaluation.description} {evaluation.descr_date()}
+ """
+ msg += (
+ f"""
+
+
+ {len(etudids_changed)} notes changées
+ ({len(etudids_without_notes_by_eval[evaluation.id])} sans notes,
+ {len(etudids_absents_by_eval[evaluation.id])} absents,
+ {nb_suppress} note supprimées)
+
+ """
+ if etudids_changed
+ else " : pas de changement
"
+ )
+ msg += ""
+ if messages:
+ msg += f"""Attention :
+
+ - {
+ '
- '.join(messages)
+ }
+
+
+
"""
+ msg += """"""
+ messages_by_eval[evaluation.id] = msg
+ return messages_by_eval, etudids_with_decisions
+
+
def _check_notes_evaluations(
evaluations: list[Evaluation],
notes_by_eval: dict[int, list[tuple[int, str]]],
diag: list[str],
+ rows: list[list[str]] | None = None,
) -> tuple[dict[int, list[tuple[int, str]]], list[int], list[int]]:
"""Vérifie que les notes pour ces évaluations sont valides.
Raise InvalidNoteValue et rempli diag si ce n'est pas le cas.
@@ -678,29 +751,40 @@ def _check_notes_evaluations(
etudids_non_inscrits,
) = sco_saisie_notes.check_notes(notes_by_eval[evaluation.id], evaluation)
if invalids:
- diag.append(
- f"Erreur: la feuille contient {len(invalids)} notes invalides
"
- )
+ diag.append(f"Erreur: la feuille contient {len(invalids)} notes invalides")
+ msg = f"""Notes invalides dans {
+ evaluation.moduleimpl.module.code} {evaluation.description} pour : """
if len(invalids) < 25:
etudsnames = [
Identite.get_etud(etudid).nom_prenom() for etudid in invalids
]
- diag.append("Notes invalides pour: " + ", ".join(etudsnames))
+ msg += ", ".join(etudsnames)
else:
- diag.append("Notes invalides pour plus de 25 étudiants")
+ msg += "plus de 25 étudiants"
+ diag.append(msg)
raise InvalidNoteValue()
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"""Ligne {index+1}:
+ {rows[index][1]} {rows[index][2]} (id={rows[index][0]})
+ """
+ else:
+ msg += "sur plus de 25 lignes"
diag.append(
f"""Erreur: la feuille contient {len(etudids_non_inscrits)
- } étudiants non inscrits"""
+ } étudiants inexistants ou non inscrits à l'évaluation
+ {evaluation.moduleimpl.module.code}
+ {evaluation.description}
+
+ """
)
- 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()
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))
+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(
- 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]]:
"""
rows: les valeurs (str) des cellules de la feuille
@@ -735,35 +872,38 @@ def _get_sheet_evaluations(
formsemestre ou evaluation doivent être indiqués.
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_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 ?)")
+
+ i, r = _xls_search_sheet_code(rows, diag)
+ if isinstance(r, int): # mono-eval
+ sheet_eval_id = r
+ if sheet_eval_id != evaluation.id:
+ diag.append(
+ f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{
+ sheet_eval_id or ('non trouvé')}' != '{evaluation.id}')"""
+ )
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:
- sheet_eval_id = int(eval_code[1:])
- except ValueError:
- sheet_eval_id = None
- if sheet_eval_id != evaluation.id:
- diag.append(
- f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{
- sheet_eval_id or ('non trouvé')}' != '{evaluation.id}')"""
- )
- 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=()):
@@ -840,7 +980,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
Étape 2 : chargement d'un fichier de notes""" # '
)
- nf = TrivialFormulator(
+ tf = TrivialFormulator(
request.base_url,
scu.get_request_args(),
(
@@ -861,23 +1001,27 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
formid="notesfile",
submitlabel="Télécharger",
)
- if nf[0] == 0:
+ if tf[0] == 0:
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(tf[1])
+ elif tf[0] == -1:
H.append("Annulation
")
- elif nf[0] == 1:
- updiag = do_evaluation_upload_xls()
- if updiag[0]:
+ elif tf[0] == 1:
+ args = scu.get_request_args()
+ 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(
f"""
Notes chargées !
- {updiag[1]}
+ {diagnostic_msg}
Notes non chargées !
- {updiag[1]}
+ {diagnostic_msg}
@@ -955,7 +1099,8 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
Remarques:
- 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);
- seules les valeurs des notes modifiées sont prises en compte;
@@ -977,3 +1122,18 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
)
H.append(html_sco_header.sco_footer())
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",
+ )
diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py
index aad7eb70..cad79c3f 100644
--- a/app/scodoc/sco_saisie_notes.py
+++ b/app/scodoc/sco_saisie_notes.py
@@ -156,7 +156,12 @@ def check_notes(
for etudid, note in notes:
if etudid not in etudids_inscrits_mod:
- etudids_non_inscrits.append(etudid)
+ # 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) #
@@ -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()
+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,
@@ -424,22 +450,8 @@ def notes_add(
for etudid, value in notes:
if check_inscription:
- msg_err, msg_warn = "", ""
- 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)
+ _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
@@ -470,14 +482,26 @@ def notes_add(
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 NotImplementedError as exc: # XXX
+
+ except Exception as exc:
log("*** exception in notes_add")
if do_it:
cnx.rollback() # abort
@@ -485,10 +509,12 @@ def notes_add(
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
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 0b4a1af6..015c98ec 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -72,10 +72,23 @@ div.scobox.explanation {
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 {
font-size: 120%;
font-weight: bold;
margin-bottom: 8px;
+ padding-left: 8px;
+ padding-top: 4px;
+ padding-bottom: 4px;
}
div.scobox-buttons {
diff --git a/app/templates/formsemestre/import_notes.j2 b/app/templates/formsemestre/import_notes.j2
index 9ca1c407..8fd75722 100644
--- a/app/templates/formsemestre/import_notes.j2
+++ b/app/templates/formsemestre/import_notes.j2
@@ -4,7 +4,9 @@
{% block styles %}
{{super()}}
{% endblock %}
@@ -35,7 +37,7 @@ Cette page permet d'importer des notes dans tout ou partie des évaluations du s
-Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le
+
Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le
ci-dessous.
Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est jamais montré aux étudiants.
@@ -47,4 +49,32 @@ Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est
+
+À l'
étape 2, indiquer le fichier Excel
+
téléchargé à l'étape 1 et dans lequel on a saisi des notes.
+
+
Remarques :
+
+ - Le fichier Excel doit impérativement être celui chargé à
+ l'étape 1 pour ce semestre. Il n'est pas possible d'utiliser
+ une liste d'appel ou autre document Excel téléchargé d'une autre page.
+
+ - 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.
+
+
+ - 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).
+
+ - Seules les valeurs des notes modifiées sont prises en
+ compte.
+
+
+
+
+
{% endblock %}
diff --git a/app/templates/formsemestre/import_notes_after.j2 b/app/templates/formsemestre/import_notes_after.j2
new file mode 100644
index 00000000..5f7222c8
--- /dev/null
+++ b/app/templates/formsemestre/import_notes_after.j2
@@ -0,0 +1,54 @@
+{% extends "sco_page.j2" %}
+{% import 'wtf.j2' as wtf %}
+
+{% block styles %}
+{{super()}}
+
+{% endblock %}
+
+{% block app_content %}
+Import de notes dans les évaluations du semestre
+
+
+
+ {% if ok %}
+ Notes importées avec succès
+ {% else %}
+ Erreur: aucune note chargée
+ {% endif %}
+
+
+ {{ diagnostic_msg | safe }}
+
+
+
+
+
+{% endblock %}
diff --git a/app/views/notes.py b/app/views/notes.py
index 3234f063..95c071a7 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -1870,15 +1870,17 @@ def formsemestre_import_notes(formsemestre_id: int):
# Handle file upload and form processing
notefile = form.notefile.data
comment = form.comment.data
- # Save the file and process form data here
- raise ScoValueError("unimplemented")
- return redirect(url_for("index"))
+ #
+ return sco_saisie_excel.formsemestre_import_notes(
+ formsemestre, notefile, comment
+ )
return render_template(
"formsemestre/import_notes.j2",
evaluations=formsemestre.get_evaluations(),
form=form,
formsemestre=formsemestre,
+ title="Importation des notes",
sco=ScoData(formsemestre=formsemestre),
)
diff --git a/sco_version.py b/sco_version.py
index b8822c3e..60714411 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,7 +1,7 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
-SCOVERSION = "9.6.992"
+SCOVERSION = "9.7.0"
SCONAME = "ScoDoc"