diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 976594300..946b4f661 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 2b9313fad..f9c92b1bf 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 3e2caf318..8609c6234 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 4982a6ae3..49f2ebd76 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
- {len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, - {len(absents)} absents, {nb_suppress} note supprimées) -
""" - if messages: - msg += f"""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 = '(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""" +