From 9b825c0fb10a1739da75f7a7bf2dcda0ec8380e4 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet <emmanuel.viennet@gmail.com> 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 | 452 ++++++++++++------ 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, 487 insertions(+), 199 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"""<div class="diag-evaluation"> - <ul> - <li><div>Module {evaluation.moduleimpl.module.code} : - évaluation {evaluation.description} {evaluation.descr_date()} - </div> - <div> - {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) - </div> - </li> - </ul> - """ - if messages: - msg += f"""<div class="warning">Attention : - <ul> - <li>{ - '</li><li>'.join(messages) - } - </li> - </ul> - </div>""" - msg += """</div>""" - 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 <a href="{status_url}">{ - module.titre or module.code}</a>""", + obj=obj_id, + text=f"""Chargement notes dans <a href="{status_url}">{modules_str}</a>""", url=status_url, max_frequency=30 * 60, # 30 minutes ) + msg = "<div>" + "\n".join(messages_by_eval.values()) + "</div>" if etudids_with_decisions: - msg += """<p class="warning"><b>Important:</b> il y avait déjà des décisions de jury - enregistrées, qui sont à revoir suite à cette modification !</p> - """ + msg = ( + """<p class="warning"><b>Important:</b> + Il y avait déjà des décisions de jury + enregistrées, qui sont à revoir suite à cette modification ! + </p> + """ + + msg + ) return True, msg except InvalidNoteValue: @@ -657,10 +639,101 @@ def do_evaluation_upload_xls() -> tuple[bool, str]: return False, msg + "<p>(pas de notes modifiées)</p>" +def _read_notes_from_rows( + rows: list[list], diag, evaluations, evaluations_col_idx, start=0 +): + """--- get notes -> list (etudid, value) + ignore toutes les lignes ne commençant pas par '!' + """ + # { evaluation_id : [ (etudid, note_value), ... ] } + notes_by_eval = defaultdict(list) + ni = start + for row in rows[start:]: + if row: + 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"""<div class="diag-evaluation"> + <ul> + <li><div class="{'diag-change' if etudids_changed else 'diag-nochange'}"> + Module {evaluation.moduleimpl.module.code} : + évaluation {evaluation.description} {evaluation.descr_date()} + """ + msg += ( + f""" + </div> + <div> + {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) + </div> + """ + if etudids_changed + else " : pas de changement</div>" + ) + msg += "</li></ul>" + if messages: + msg += f"""<div class="warning">Attention : + <ul> + <li>{ + '</li><li>'.join(messages) + } + </li> + </ul> + </div>""" + msg += """</div>""" + 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</p>" - ) + 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"""<li>Ligne {index+1}: + {rows[index][1]} {rows[index][2]} (id={rows[index][0]}) + </li>""" + else: + msg += "<li>sur plus de 25 lignes</li>" diag.append( f"""Erreur: la feuille contient {len(etudids_non_inscrits) - } étudiants non inscrits</p>""" + } étudiants inexistants ou non inscrits à l'évaluation + {evaluation.moduleimpl.module.code} + {evaluation.description} + <ul>{msg}</ul> + """ ) - if len(etudids_non_inscrits) < 25: - diag.append( - "etudid invalides (inexistants ou non inscrits): " - + ", ".join(str(etudid) for etudid in etudids_non_inscrits) - ) - else: - diag.append("etudid invalides sur plus de 25 lignes") raise InvalidNoteValue() 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 ?)") - 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} + 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() + 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=()): <span class="titredivsaisienote">Étape 2 : chargement d'un fichier de notes</span>""" # ' ) - 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( """<p>Le fichier doit être un fichier tableur obtenu via l'étape 1 ci-dessus, puis complété et enregistré au format Excel. </p>""" ) - H.append(nf[1]) - elif nf[0] == -1: + H.append(tf[1]) + elif tf[0] == -1: H.append("<p>Annulation</p>") - 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""" <div class="notes-chargees"> <div><b>Notes chargées !</b></div> - {updiag[1]} + {diagnostic_msg} </div> <a class="stdlink" href="{ url_for("notes.moduleimpl_status", @@ -898,7 +1042,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()): H.append( f""" <p class="redboldtext">Notes non chargées !</p> - {updiag[1]} + {diagnostic_msg} <p><a class="stdlink" href="{url_for("notes.saisie_notes_tableur", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id) }"> @@ -955,7 +1099,8 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()): Remarques: <ul> <li>le fichier Excel peut être incomplet: on peut ne saisir que quelques notes - et répéter l'opération (en téléchargeant un nouveau fichier) plus tard; + et répéter l'opération plus tard (en téléchargeant un nouveau fichier ou en + passant par le formulaire de saisie); </li> <li>seules les valeurs des notes modifiées sont prises en compte; </li> @@ -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()}} <style> - +div.vspace { + margin-top: 24px; +} </style> {% endblock %} @@ -35,7 +37,7 @@ Cette page permet d'importer des notes dans tout ou partie des évaluations du s </ul> </div> -<div class="help" style="margin-top: 24px;">Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le +<div class="help vspace">Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le ci-dessous. Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est jamais montré aux étudiants. </div> @@ -47,4 +49,32 @@ Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est </div> +<div class="help vspace"> +À l'<b>étape 2</b>, indiquer le fichier Excel + <em>téléchargé à l'étape 1</em> et dans lequel on a saisi des notes. + <div class="vspace"> + <b>Remarques :</b> + <ul> + <li>Le fichier Excel <em>doit impérativement être celui chargé à + l'étape 1 pour ce semestre</em>. Il n'est pas possible d'utiliser + une liste d'appel ou autre document Excel téléchargé d'une autre page. + </li> + <li>Ne pas supprimer les lignes et colonnes cachées, qui + contiennent des codes. Le fichier exporté contient toutles les + évaluations du semestre, vous pouvez au besoin supprimer certaines + colonnes d'évaluations. + </li> + + <li>Le fichier peut être incomplet: on + peut ne saisir que quelques notes et répéter l'opération plus tard (en + téléchargeant un nouveau fichier ou en passant par le formulaire de + saisie). + </li> + <li>Seules les valeurs des notes modifiées sont prises en + compte. + </li> + </ul> + </div> +</div> + {% endblock %} 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()}} +<style> +.import-diag ul.tf-msg { + padding-top: 8px; + padding-bottom: 8px; +} +.diag-change { + font-weight: bold; +} +.diag-nochange { + color: gray; +} +</style> +{% endblock %} + +{% block app_content %} +<h2>Import de notes dans les évaluations du semestre</h2> + +<div class="scobox {{ 'success' if ok else 'failure' }}"> + <div class="scobox-title"> + {% if ok %} + Notes importées avec succès + {% else %} + Erreur: aucune note chargée + {% endif %} + </div> + <div class="import-diag"> + {{ diagnostic_msg | safe }} + </div> +</div> + +<div class="scobox"> + + <ul> + <li><a class="stdlink" + href="{{url_for('notes.formsemestre_recapcomplet', + scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id, + tabformat='evals')}}"> + Tableau de <em>toutes</em> les notes du semestre + </a> + </li> + <li><a class="stdlink" + href="{{url_for('notes.formsemestre_import_notes', + scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id)}}"> + Importer d'autres notes + </a> + </li> +</div> + +{% endblock %} 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" -- GitLab