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"""
+ + """ + 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 : - -
""" 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]} +
Formulaire de saisie des notes -

""" +
""" ) 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