From c4b44a10227fbc848dc1efbae4b5ed7f7de6d26d Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet
Date: Sun, 30 Jun 2024 23:00:42 +0200
Subject: [PATCH] =?UTF-8?q?Chargement=20notes=20excel:=20r=C3=A9organisati?=
=?UTF-8?q?on=20du=20code?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/models/etudiants.py | 24 +-
app/models/moduleimpls.py | 34 +-
app/scodoc/sco_groups.py | 2 +-
app/scodoc/sco_saisie_excel.py | 570 +++++++++++++++++++++------------
app/scodoc/sco_saisie_notes.py | 80 +++--
app/scodoc/sco_ue_external.py | 2 +-
app/scodoc/sco_undo_notes.py | 11 +-
app/static/css/scodoc.css | 5 +
tests/unit/yaml_setup.py | 1 -
9 files changed, 466 insertions(+), 263 deletions(-)
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 97659430..946b4f66 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -197,18 +197,28 @@ class Identite(models.ScoDocModel):
return cls.query.filter_by(**args).first_or_404()
@classmethod
- def get_etud(cls, etudid: int) -> "Identite":
- """Etudiant ou 404, cherche uniquement dans le département courant"""
+ def get_etud(cls, etudid: int, accept_none=False) -> "Identite":
+ """Etudiant ou 404 (ou None si accept_none),
+ cherche uniquement dans le département courant.
+ Si accept_none, return None si l'id est invalide ou ne correspond
+ pas à un étudiant.
+ """
if not isinstance(etudid, int):
try:
etudid = int(etudid)
except (TypeError, ValueError):
+ if accept_none:
+ return None
abort(404, "etudid invalide")
- if g.scodoc_dept:
- return cls.query.filter_by(
- id=etudid, dept_id=g.scodoc_dept_id
- ).first_or_404()
- return cls.query.filter_by(id=etudid).first_or_404()
+
+ query = (
+ cls.query.filter_by(id=etudid, dept_id=g.scodoc_dept_id)
+ if g.scodoc_dept
+ else cls.query.filter_by(id=etudid)
+ )
+ if accept_none:
+ return query.first()
+ return query.first_or_404()
@classmethod
def create_etud(cls, **args) -> "Identite":
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index 2b9313fa..f9c92b1b 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -274,21 +274,39 @@ class ModuleImpl(ScoDocModel):
return False
return True
- def est_inscrit(self, etud: Identite) -> bool:
+ def est_inscrit(self, etud: Identite):
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).
(lent, pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
- Retourne Vrai si inscrit au module, faux sinon.
+ Retourne ModuleImplInscription si inscrit au module, False sinon.
"""
+ # vérifie inscrit au moduleimpl ET au formsemestre
+ from app.models.formsemestre import FormSemestre, FormSemestreInscription
- is_module: int = (
- ModuleImplInscription.query.filter_by(
- etudid=etud.id, moduleimpl_id=self.id
- ).count()
- > 0
+ inscription = (
+ ModuleImplInscription.query.filter_by(etudid=etud.id, moduleimpl_id=self.id)
+ .join(ModuleImpl)
+ .join(FormSemestre)
+ .join(FormSemestreInscription)
+ .filter_by(etudid=etud.id)
+ .first()
)
- return is_module
+ return inscription or False
+
+ def query_inscriptions(self) -> Query:
+ """Query ModuleImplInscription: inscrits au moduleimpl et au formsemestre
+ (pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
+ """
+ from app.models.formsemestre import FormSemestre, FormSemestreInscription
+
+ return (
+ ModuleImplInscription.query.filter_by(moduleimpl_id=self.id)
+ .join(ModuleImpl)
+ .join(FormSemestre)
+ .join(FormSemestreInscription)
+ .filter_by(etudid=ModuleImplInscription.etudid)
+ )
# Enseignants (chargés de TD ou TP) d'un moduleimpl
diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py
index 3e2caf31..8609c623 100644
--- a/app/scodoc/sco_groups.py
+++ b/app/scodoc/sco_groups.py
@@ -1472,7 +1472,7 @@ def do_evaluation_listeetuds_groups(
include_demdef: bool = False,
) -> list[tuple[int, str]]:
"""Donne la liste non triée des etudids inscrits à cette évaluation dans les
- groupes indiqués.
+ groupes indiqués (donc inscrits au modimpl ET au formsemestre).
Si getallstudents==True, donne tous les étudiants inscrits à cette
evaluation.
Si include_demdef, compte aussi les etudiants démissionnaires et défaillants
diff --git a/app/scodoc/sco_saisie_excel.py b/app/scodoc/sco_saisie_excel.py
index 4982a6ae..49f2ebd7 100644
--- a/app/scodoc/sco_saisie_excel.py
+++ b/app/scodoc/sco_saisie_excel.py
@@ -24,11 +24,11 @@
"""Fichier excel de saisie des notes
"""
-
+from collections import defaultdict
from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from openpyxl.styles.numbers import FORMAT_GENERAL
-from flask import g, request, url_for
+from flask import flash, g, request, url_for
from flask_login import current_user
from app.models import Evaluation, Identite, Module, ScolarNews
@@ -49,9 +49,7 @@ from app.scodoc.TrivialFormulator import TrivialFormulator
FONT_NAME = "Arial"
-def excel_feuille_saisie(
- evaluation: "Evaluation", titreannee, description, rows: list[dict]
-):
+def excel_feuille_saisie(evaluation: "Evaluation", rows: list[dict]):
"""Genere feuille excel pour saisie des notes.
E: evaluation (dict)
lines: liste de tuples
@@ -59,106 +57,15 @@ def excel_feuille_saisie(
"""
sheet_name = "Saisie notes"
ws = ScoExcelSheet(sheet_name)
+ styles = _build_styles()
+ nb_lines_titles = _insert_top_title(ws, styles, evaluation=evaluation)
- # fontes
- font_base = Font(name=FONT_NAME, size=12)
- font_bold = Font(name=FONT_NAME, bold=True)
- font_italic = Font(name=FONT_NAME, size=12, italic=True, color=COLORS.RED.value)
- font_titre = Font(name=FONT_NAME, bold=True, size=14)
- font_purple = Font(name=FONT_NAME, color=COLORS.PURPLE.value)
- font_brown = Font(name=FONT_NAME, color=COLORS.BROWN.value)
- font_blue = Font(name=FONT_NAME, size=9, color=COLORS.BLUE.value)
-
- # bordures
- side_thin = Side(border_style="thin", color=COLORS.BLACK.value)
- border_top = Border(top=side_thin)
- border_right = Border(right=side_thin)
-
- # fonds
- fill_light_yellow = PatternFill(
- patternType="solid", fgColor=COLORS.LIGHT_YELLOW.value
- )
-
- # styles
- styles = {
- "base": {"font": font_base},
- "titres": {"font": font_titre},
- "explanation": {"font": font_italic},
- "read-only": { # cells read-only
- "font": font_purple,
- "border": border_right,
- },
- "dem": {
- "font": font_brown,
- "border": border_top,
- },
- "nom": { # style pour nom, prenom, groupe
- "font": font_base,
- "border": border_top,
- },
- "notes": {
- "alignment": Alignment(horizontal="right"),
- "font": font_bold,
- "number_format": FORMAT_GENERAL,
- "fill": fill_light_yellow,
- "border": border_top,
- },
- "comment": {
- "font": font_blue,
- "border": border_top,
- },
- }
-
- # filtre auto excel sur colonnes
- filter_top = 8
- filter_bottom = 8 + len(rows)
- filter_left = "A" # important: le code etudid en col A doit être trié en même temps
- filter_right = "G"
- ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")
-
- # ligne de titres (utilise prefix pour se placer à partir de la colonne B)
- ws.append_single_cell_row(
- "Feuille saisie note (à enregistrer au format excel)",
- styles["titres"],
- prefix=[""],
- )
- # lignes d'instructions
- ws.append_single_cell_row(
- "Saisir les notes dans la colonne E (cases jaunes)",
- styles["explanation"],
- prefix=[""],
- )
- ws.append_single_cell_row(
- "Ne pas modifier les cases en mauve !", styles["explanation"], prefix=[""]
- )
- # Nom du semestre
- ws.append_single_cell_row(
- scu.unescape_html(titreannee), styles["titres"], prefix=[""]
- )
- # description evaluation
- ws.append_single_cell_row(
- scu.unescape_html(description), styles["titres"], prefix=[""]
- )
- ws.append_single_cell_row(
- f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})",
- styles["base"],
- prefix=[""],
- )
- # ligne blanche
- ws.append_blank_row()
- # code et titres colonnes
- ws.append_row(
- [
- ws.make_cell(f"!{evaluation.id}", styles["read-only"]),
- ws.make_cell("Nom", styles["titres"]),
- ws.make_cell("Prénom", styles["titres"]),
- ws.make_cell("Groupe", styles["titres"]),
- ws.make_cell(
- f"Note sur {(evaluation.note_max or 0.0):g}", styles["titres"]
- ),
- ws.make_cell("Remarque", styles["titres"]),
- ws.make_cell("NIP", styles["titres"]),
- ]
+ _insert_line_titles(
+ ws,
+ nb_lines_titles,
+ nb_rows_in_table=len(rows),
+ evaluations=[evaluation],
+ styles=styles,
)
# etudiants
@@ -209,6 +116,151 @@ def excel_feuille_saisie(
)
+def _insert_line_titles(
+ ws,
+ current_line,
+ nb_rows_in_table: int = 0,
+ evaluations: list[Evaluation] = None,
+ styles: dict = None,
+) -> int:
+ """Ligne(s) des titres, avec filtre auto excel.
+ current_line : nb de lignes déjà dans le tableau
+ nb_rows_in_table: nombre de ligne dans tableau à trier pour le filtre (nb d'étudiants)
+ Renvoie nombre de lignes ajoutées (si plusieurs évaluations, indique les eval
+ ids au dessus des titres)
+ """
+ # WIP
+ assert len(evaluations) == 1
+ evaluation = evaluations[0]
+
+ # Filtre auto excel sur colonnes
+ filter_top = current_line + 1
+ filter_bottom = current_line + 1 + nb_rows_in_table
+ filter_left = "A" # important: le code etudid en col A doit être trié en même temps
+ filter_right = "G"
+ ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")
+
+ # Code et titres colonnes
+ ws.append_row(
+ [
+ ws.make_cell(f"!{evaluation.id}", styles["read-only"]),
+ ws.make_cell("Nom", styles["titres"]),
+ ws.make_cell("Prénom", styles["titres"]),
+ ws.make_cell("Groupe", styles["titres"]),
+ ws.make_cell(
+ f"Note sur {(evaluation.note_max or 0.0):g}", styles["titres"]
+ ),
+ ws.make_cell("Remarque", styles["titres"]),
+ ws.make_cell("NIP", styles["titres"]),
+ ]
+ )
+ return 1 # WIP
+
+
+def _build_styles() -> dict:
+ """Déclare le styles excel"""
+
+ # bordures
+ side_thin = Side(border_style="thin", color=COLORS.BLACK.value)
+ border_top = Border(top=side_thin)
+
+ # fonds
+ fill_light_yellow = PatternFill(
+ patternType="solid", fgColor=COLORS.LIGHT_YELLOW.value
+ )
+
+ # styles
+ font_base = Font(name=FONT_NAME, size=12)
+ return {
+ "base": {"font": font_base},
+ "titres": {"font": Font(name=FONT_NAME, bold=True, size=14)},
+ "explanation": {
+ "font": Font(name=FONT_NAME, size=12, italic=True, color=COLORS.RED.value)
+ },
+ "read-only": { # cells read-only
+ "font": Font(name=FONT_NAME, color=COLORS.PURPLE.value),
+ "border": Border(right=side_thin),
+ },
+ "dem": {
+ "font": Font(name=FONT_NAME, color=COLORS.BROWN.value),
+ "border": border_top,
+ },
+ "nom": { # style pour nom, prenom, groupe
+ "font": font_base,
+ "border": border_top,
+ },
+ "notes": {
+ "alignment": Alignment(horizontal="right"),
+ "font": Font(name=FONT_NAME, bold=True),
+ "number_format": FORMAT_GENERAL,
+ "fill": fill_light_yellow,
+ "border": border_top,
+ },
+ "comment": {
+ "font": Font(name=FONT_NAME, size=9, color=COLORS.BLUE.value),
+ "border": border_top,
+ },
+ }
+
+
+def _insert_top_title(
+ ws, styles: dict, evaluation: Evaluation = None, description=""
+) -> int:
+ """Insère les lignes de titre de la feuille (suivies d'une ligne blanche)
+ renvoie le nb de lignes insérées
+ """
+ n = 0
+ # ligne de titres (utilise prefix pour se placer à partir de la colonne B)
+ ws.append_single_cell_row(
+ "Feuille saisie note (à enregistrer au format excel)",
+ styles["titres"],
+ prefix=[""],
+ )
+ n += 1
+ # lignes d'instructions
+ ws.append_single_cell_row(
+ "Saisir les notes dans la colonne E (cases jaunes)",
+ styles["explanation"],
+ prefix=[""],
+ )
+ ws.append_single_cell_row(
+ "Ne pas modifier les cases en mauve !", styles["explanation"], prefix=[""]
+ )
+ n += 2
+ # Nom du semestre
+ titre_annee = evaluation.moduleimpl.formsemestre.titre_annee()
+ ws.append_single_cell_row(
+ scu.unescape_html(titre_annee), styles["titres"], prefix=[""]
+ )
+ n += 1
+ # description evaluation
+ date_str = (
+ f"""du {evaluation.date_debut.strftime(scu.DATE_FMT)}"""
+ if evaluation.date_debut
+ else "(sans date)"
+ )
+ eval_titre = f"""{evaluation.description if evaluation.description else "évaluation"
+ } {date_str}"""
+
+ mod_responsable = sco_users.user_info(evaluation.moduleimpl.responsable_id)
+ description = f"""{eval_titre} en {evaluation.moduleimpl.module.abbrev or ""} ({
+ evaluation.moduleimpl.module.code
+ }) resp. {mod_responsable["prenomnom"]}"""
+ ws.append_single_cell_row(
+ scu.unescape_html(description), styles["titres"], prefix=[""]
+ )
+ ws.append_single_cell_row(
+ f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})",
+ styles["base"],
+ prefix=[""],
+ )
+ n += 2
+ # ligne blanche
+ ws.append_blank_row()
+ n += 1
+ return n
+
+
def _insert_bottom_help(ws, styles: dict):
ws.append_row([None, ws.make_cell("Code notes", styles["titres"])])
ws.append_row(
@@ -254,24 +306,11 @@ def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None):
group_ids = group_ids or []
modimpl = evaluation.moduleimpl
formsemestre = modimpl.formsemestre
- mod_responsable = sco_users.user_info(modimpl.responsable_id)
+
if evaluation.date_debut:
indication_date = evaluation.date_debut.date().isoformat()
else:
indication_date = scu.sanitize_filename(evaluation.description)[:12]
- eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}"
-
- date_str = (
- f"""du {evaluation.date_debut.strftime(scu.DATE_FMT)}"""
- if evaluation.date_debut
- else "(sans date)"
- )
- eval_titre = f"""{evaluation.description if evaluation.description else "évaluation"
- } {date_str}"""
-
- description = f"""{eval_titre} en {evaluation.moduleimpl.module.abbrev or ""} ({
- evaluation.moduleimpl.module.code
- }) resp. {mod_responsable["prenomnom"]}"""
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids=group_ids,
@@ -315,99 +354,110 @@ def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None):
}
)
+ eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}"
filename = f"notes_{eval_name}_{gr_title_filename}"
- xls = excel_feuille_saisie(
- evaluation, formsemestre.titre_annee(), description, rows=rows
- )
+ xls = excel_feuille_saisie(evaluation, rows=rows)
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
-def do_evaluation_upload_xls():
+def do_evaluation_upload_xls() -> tuple[bool, str]:
"""
Soumission d'un fichier XLS (evaluation_id, notefile)
+ return:
+ ok: bool
+ msg: message diagonistic à affciher
"""
args = scu.get_request_args()
- evaluation_id = int(args["evaluation_id"])
comment = args["comment"]
- evaluation = Evaluation.get_evaluation(evaluation_id)
+ 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, lines = sco_excel.excel_file_to_list(args["notefile"])
+ diag, rows = sco_excel.excel_file_to_list(args["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)")
+ if not rows:
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()
+ row_title_idx, evaluations, evaluations_col_idx = _get_sheet_evaluations(
+ rows, evaluation=evaluation, diag=diag
+ )
# --- 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, _ = sco_saisie_notes.check_notes(
- notes, evaluation
- )
- if invalids:
- diag.append(
- f"Erreur: la feuille contient {len(invalids)} notes invalides
"
- )
- if len(invalids) < 25:
- etudsnames = [
- Identite.get_etud(etudid).nom_prenom() for etudid in invalids
- ]
- diag.append("Notes invalides pour: " + ", ".join(etudsnames))
- raise InvalidNoteValue()
+ 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
- etudids_changed, nb_suppress, etudids_with_decisions, messages = (
- sco_saisie_notes.notes_add(
- current_user, evaluation_id, valid_notes, comment
- )
+ # -- 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)
)
- # news
+
+ # -- 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,
+ )
+ )
+ 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",
@@ -424,24 +474,11 @@ def do_evaluation_upload_xls():
max_frequency=30 * 60, # 30 minutes
)
- msg = f"""
- {len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes,
- {len(absents)} absents, {nb_suppress} note supprimées)
-
"""
- if messages:
- msg += f"""Attention :
-
- - {
- '
- '.join(messages)
- }
-
-
-
"""
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 !
+ msg += """Important: il y avait déjà des décisions de jury
+ enregistrées, qui sont à revoir suite à cette modification !
"""
- return 1, msg
+ return True, msg
except InvalidNoteValue:
if diag:
@@ -452,10 +489,119 @@ def do_evaluation_upload_xls():
)
else:
msg = ''
- return 0, msg + "(pas de notes modifiées)
"
+ return False, msg + "(pas de notes modifiées)
"
-def saisie_notes_tableur(evaluation_id, group_ids=()):
+def _check_notes_evaluations(
+ evaluations: list[Evaluation],
+ notes_by_eval: dict[int, list[tuple[int, str]]],
+ diag: list[str],
+) -> 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.
+ Renvoie un dict donnant la liste des notes converties pour chaque évaluation.
+ """
+ valid_notes_by_eval = {}
+ etudids_without_notes_by_eval = {}
+ etudids_absents_by_eval = {}
+ for evaluation in evaluations:
+ (
+ valid_notes_by_eval[evaluation.id],
+ invalids,
+ etudids_without_notes_by_eval[evaluation.id],
+ etudids_absents_by_eval[evaluation.id],
+ 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"
+ )
+ if len(invalids) < 25:
+ etudsnames = [
+ Identite.get_etud(etudid).nom_prenom() for etudid in invalids
+ ]
+ diag.append("Notes invalides pour: " + ", ".join(etudsnames))
+ else:
+ diag.append("Notes invalides pour plus de 25 étudiants")
+ raise InvalidNoteValue()
+ if etudids_non_inscrits:
+ diag.append(
+ f"""Erreur: la feuille contient {len(etudids_non_inscrits)
+ } étudiants non inscrits"""
+ )
+ 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
+
+
+def _read_notes_evaluations(
+ row: list[str],
+ etud: Identite,
+ evaluations: list[Evaluation],
+ notes_by_eval: dict[int, list[tuple[int, str]]],
+ evaluations_col_idx: dict[int, int],
+):
+ """Lit les notes sur une ligne (étudiant etud).
+ Ne vérifie pas la valeur de la note.
+ """
+ for evaluation in evaluations:
+ col_idx = evaluations_col_idx[evaluation.id]
+ if len(row) > col_idx:
+ val = row[col_idx].strip()
+ else:
+ val = "" # ligne courte: cellule vide
+ notes_by_eval[evaluation.id].append((etud.id, val))
+
+
+def _get_sheet_evaluations(
+ rows: list[list[str]], evaluation: Evaluation | None = None, diag: list[str] = None
+) -> tuple[int, list[Evaluation], dict[int, int]]:
+ """
+ rows: les valeurs (str) des cellules de la feuille
+ diag: liste dans laquelle accumuler les messages d'erreur
+ evaluation (optionnel): l'évaluation que l'on cherche à remplir (pour feuille mono-évaluation)
+ formsemestre (optionnel): le formsemestre dans lequel sont les évaluations à remplir
+ 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)
+ 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 ?)")
+ 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}
+
+
+def saisie_notes_tableur(evaluation_id: int, group_ids=()):
"""Saisie des notes via un fichier Excel"""
evaluation = Evaluation.query.get_or_404(evaluation_id)
moduleimpl_id = evaluation.moduleimpl.id
@@ -562,9 +708,11 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
elif nf[0] == 1:
updiag = do_evaluation_upload_xls()
if updiag[0]:
- H.append(updiag[1])
H.append(
- f"""Notes chargées.
+ f"""
+
Notes chargées.
+ {updiag[1]}
+
"""
)
else:
H.append(
diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py
index 6124410d..aad7eb70 100644
--- a/app/scodoc/sco_saisie_notes.py
+++ b/app/scodoc/sco_saisie_notes.py
@@ -76,15 +76,17 @@ from app.scodoc.sco_utils import ModuleType
def convert_note_from_string(
note: str,
- note_max,
- note_min=scu.NOTES_MIN,
+ note_max: float,
+ note_min: float = scu.NOTES_MIN,
etudid: int = None,
absents: list[int] = None,
- tosuppress: list[int] = None,
invalids: list[int] = None,
-):
+) -> tuple[float, bool]:
"""converti une valeur (chaine saisie) vers une note numérique (float)
- Les listes absents, tosuppress et invalids sont modifiées
+ 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
@@ -98,7 +100,6 @@ def convert_note_from_string(
note_value = scu.NOTES_ATTENTE
elif note[:3] == "SUP":
note_value = scu.NOTES_SUPPRESS
- tosuppress.append(etudid)
else:
try:
note_value = float(note)
@@ -111,12 +112,22 @@ def convert_note_from_string(
return note_value, invalid
-def check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
- """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)
+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, etudids_without_notes, etudids_absents, etudid_to_suppress
+ 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
@@ -133,18 +144,25 @@ def check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
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
+ # 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:
- note = str(note).strip().upper()
+ if etudid not in etudids_inscrits_mod:
+ 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:
@@ -154,7 +172,6 @@ def check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
note_min=note_min,
etudid=etudid,
absents=etudids_absents,
- tosuppress=etudid_to_suppress,
invalids=etudids_invalids,
)
if not invalid:
@@ -166,7 +183,7 @@ def check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
etudids_invalids,
etudids_without_notes,
etudids_absents,
- etudid_to_suppress,
+ etudids_non_inscrits,
)
@@ -387,6 +404,8 @@ def notes_add(
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
@@ -396,10 +415,7 @@ def notes_add(
messages = []
# Vérifie inscription au module (même DEM/DEF)
etudids_inscrits_mod = {
- x[0]
- for x in sco_groups.do_evaluation_listeetuds_groups(
- evaluation_id, getallstudents=True, include_demdef=True
- )
+ 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 = (
@@ -759,13 +775,17 @@ def get_sorted_etuds_notes(
e["val"] = scu.fmt_note(
notes_db[etudid]["value"], fixed_precision_str=False
)
- comment = notes_db[etudid]["comment"]
- if comment is None:
- comment = ""
- e["explanation"] = "%s (%s) %s" % (
- notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M"),
- notes_db[etudid]["uid"],
- comment,
+ 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"] = ""
diff --git a/app/scodoc/sco_ue_external.py b/app/scodoc/sco_ue_external.py
index ff5bca9b..34bcff77 100644
--- a/app/scodoc/sco_ue_external.py
+++ b/app/scodoc/sco_ue_external.py
@@ -355,7 +355,7 @@ def external_ue_create_form(formsemestre_id: int, etudid: int):
else:
note = tf[2]["note"].strip().upper()
note_value, invalid = sco_saisie_notes.convert_note_from_string(
- note, 20.0, etudid=etudid, absents=[], tosuppress=[], invalids=[]
+ note, 20.0, etudid=etudid, absents=[], invalids=[]
)
if invalid:
return (
diff --git a/app/scodoc/sco_undo_notes.py b/app/scodoc/sco_undo_notes.py
index a8da7995..b42dd6b7 100644
--- a/app/scodoc/sco_undo_notes.py
+++ b/app/scodoc/sco_undo_notes.py
@@ -46,7 +46,7 @@ Opérations:
"""
import datetime
-from flask import request
+from flask import g, request, url_for
from app.models import Evaluation, FormSemestre
from app.scodoc.intervals import intervalmap
@@ -164,9 +164,12 @@ def evaluation_list_operations(evaluation_id: int):
columns_ids=columns_ids,
rows=operations,
html_sortable=False,
- html_title=f"""
Opérations sur l'évaluation {evaluation.description} {
- evaluation.date_debut.strftime("du %d/%m/%Y") if evaluation.date_debut else "(sans date)"
- }
""",
+ html_title=f"""
Opérations sur l'évaluation
+ {evaluation.description}
+ {evaluation.date_debut.strftime("du %d/%m/%Y") if evaluation.date_debut else "(sans date)"}
+
""",
preferences=sco_preferences.SemPreferences(
evaluation.moduleimpl.formsemestre_id
),
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 7082ccff..74341729 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -4908,4 +4908,9 @@ div.cas_etat_certif_ssl {
margin-bottom: 8px;
font-style: italic;
color: rgb(231, 0, 0);
+}
+
+
+.diag-evaluation {
+ color: green;
}
\ No newline at end of file
diff --git a/tests/unit/yaml_setup.py b/tests/unit/yaml_setup.py
index 142a3c6e..d2040ea5 100644
--- a/tests/unit/yaml_setup.py
+++ b/tests/unit/yaml_setup.py
@@ -204,7 +204,6 @@ def note_les_modules(doc: dict, formsemestre_titre: str = ""):
note_min=scu.NOTES_MIN,
etudid=etud.id,
absents=[],
- tosuppress=[],
invalids=[],
)
assert not invalid # valeur note invalide