From 62e4481c77c4e3560cdfa5192e871bc221824632 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 11 Jul 2024 19:45:52 +0200 Subject: [PATCH] =?UTF-8?q?WIP:=20Import=20de=20toutes=20les=20notes=20d'u?= =?UTF-8?q?n=20semestre:=20g=C3=A9n=C3=A9ration=20de=20la=20feuille.=20D?= =?UTF-8?q?=C3=A9but=20de=20#942.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_excel.py | 19 +- app/scodoc/sco_formsemestre_status.py | 6 + app/scodoc/sco_saisie_excel.py | 285 ++++++++++++++++----- app/templates/formsemestre/import_notes.j2 | 50 ++++ app/views/notes.py | 59 +++++ sco_version.py | 2 +- 6 files changed, 353 insertions(+), 68 deletions(-) create mode 100644 app/templates/formsemestre/import_notes.j2 diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index 1bae0cdd7..57dd86afb 100644 --- a/app/scodoc/sco_excel.py +++ b/app/scodoc/sco_excel.py @@ -33,6 +33,7 @@ import io import time from enum import Enum from tempfile import NamedTemporaryFile +from typing import AnyStr import openpyxl.utils.datetime from openpyxl.styles.numbers import FORMAT_NUMBER_00, FORMAT_GENERAL, FORMAT_DATE_DDMMYY @@ -250,10 +251,10 @@ class ScoExcelSheet: return idx if idx < 26: # one letter key return chr(idx + 65) - else: # two letters AA..ZZ - first = (idx // 26) + 66 - second = (idx % 26) + 65 - return "" + chr(first) + chr(second) + # two letters AA..ZZ + first = (idx // 26) + 64 + second = (idx % 26) + 65 + return "" + chr(first) + chr(second) def set_column_dimension_width(self, cle=None, value: int | str | list = 21): """Détermine la largeur d'une colonne. @@ -295,7 +296,7 @@ class ScoExcelSheet: self.ws.row_dimensions[cle].hidden = value def set_column_dimension_hidden(self, cle, value): - """Masque ou affiche une ligne. + """Masque ou affiche une colonne. cle -- identifie la colonne (1...) value -- boolean (vrai = colonne cachée) """ @@ -331,6 +332,7 @@ class ScoExcelSheet: else: min_row = self.ws.min_row if min_row is None else min_row max_row = self.ws.max_row if max_row is None else max_row + for row in range(min_row, max_row + 1): cell = self.ws[f"{column_letter}{row}"] cell_value = str(cell.value) @@ -339,10 +341,13 @@ class ScoExcelSheet: ) # Set the column widths based on the maximum length found + # (nb: the width is expressed in characters, in the default font) for col, width in col_widths.items(): self.ws.column_dimensions[col].width = width - def make_cell(self, value: any = None, style: dict = None, comment=None): + def make_cell( + self, value: any = None, style: dict = None, comment=None + ) -> WriteOnlyCell: """Construit une cellule. value -- contenu de la cellule (texte, numérique, booléen ou date) style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié @@ -434,7 +439,7 @@ class ScoExcelSheet: for row in self.rows: self.ws.append(row) - def generate(self, column_widths=None): + def generate(self, column_widths=None) -> AnyStr: """génération d'un classeur mono-feuille""" # this method makes sense for standalone worksheet (else call workbook.generate()) if self.wb is None: # embeded sheet diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 19ebe1fc1..30ba7ebf5 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -427,6 +427,12 @@ def formsemestre_status_menubar(formsemestre: FormSemestre | None) -> str: "endpoint": "notes.formsemestre_list_saisies_notes", "args": {"formsemestre_id": formsemestre_id}, }, + { + "title": "Importer les notes", + "endpoint": "notes.formsemestre_import_notes", + "args": {"formsemestre_id": formsemestre_id}, + "enabled": formsemestre.est_chef_or_diretud(), + }, ] menu_jury = [ { diff --git a/app/scodoc/sco_saisie_excel.py b/app/scodoc/sco_saisie_excel.py index 2cdd96b32..620111dc3 100644 --- a/app/scodoc/sco_saisie_excel.py +++ b/app/scodoc/sco_saisie_excel.py @@ -25,17 +25,20 @@ """Fichier excel de saisie des notes """ from collections import defaultdict -from openpyxl.styles import Font, Border, Side, Alignment, PatternFill +from typing import AnyStr + +from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side from openpyxl.styles.numbers import FORMAT_GENERAL -from flask import flash, g, request, url_for +from flask import g, request, url_for from flask_login import current_user -from app.models import Evaluation, Identite, Module, ScolarNews +from app.models import Evaluation, FormSemestre, Identite, Module, ScolarNews from app.scodoc.sco_excel import COLORS, ScoExcelSheet from app.scodoc import ( html_sco_header, sco_evaluations, + sco_evaluation_db, sco_excel, sco_groups, sco_groups_view, @@ -49,14 +52,14 @@ from app.scodoc.TrivialFormulator import TrivialFormulator FONT_NAME = "Arial" -def excel_feuille_saisie(evaluation: "Evaluation", rows: list[dict]): - """Genere feuille excel pour saisie des notes. - E: evaluation (dict) - lines: liste de tuples +def excel_feuille_saisie(evaluation: "Evaluation", rows: list[dict]) -> AnyStr: + """Génère feuille excel pour saisie des notes dans l'evaluation + - evaluation + - rows: liste de dict (etudid, nom, prenom, etat, groupe, val, explanation) + Return excel data. """ - sheet_name = "Saisie notes" - ws = ScoExcelSheet(sheet_name) + ws = ScoExcelSheet("Saisie notes") styles = _build_styles() nb_lines_titles = _insert_top_title(ws, styles, evaluation=evaluation) @@ -100,6 +103,8 @@ def excel_feuille_saisie(evaluation: "Evaluation", rows: list[dict]): # explication en bas _insert_bottom_help(ws, styles) + + # Hide column A (codes étudiants) ws.set_column_dimension_hidden("A", True) # colonne etudid cachée ws.set_column_dimension_hidden("G", True) # colonne NIP cachée @@ -122,27 +127,46 @@ def _insert_line_titles( nb_rows_in_table: int = 0, evaluations: list[Evaluation] = None, styles: dict = None, -) -> int: - """Ligne(s) des titres, avec filtre auto excel. + multi_eval=False, +) -> dict: + """Insère ligne 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) + multi_eval: si vrai, titres pour plusieurs évaluations (feuille import semestre) + + Return dict giving (title) column widths """ - # WIP - assert len(evaluations) == 1 - evaluation = evaluations[0] + # La colonne de gauche (utilisée pour cadrer le filtre) + # est G si une seule eval + right_column = ScoExcelSheet.i2col(3 + len(evaluations)) if multi_eval else "G" # 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" + filter_right = right_column ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}") # Code et titres colonnes - ws.append_row( - [ + if multi_eval: + cells = [ + ws.make_cell("", 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"""{evaluation.moduleimpl.module.code + } : {evaluation.description} (/{(evaluation.note_max or 0.0):g})""", + styles["titres"], + comment=f"""{evaluation.descr_date() + }, notes sur {(evaluation.note_max or 0.0):g}""", + ) + for evaluation in evaluations + ] + else: + evaluation = evaluations[0] + cells = [ ws.make_cell(f"!{evaluation.id}", styles["read-only"]), ws.make_cell("Nom", styles["titres"]), ws.make_cell("Prénom", styles["titres"]), @@ -153,21 +177,35 @@ def _insert_line_titles( ws.make_cell("Remarque", styles["titres"]), ws.make_cell("NIP", styles["titres"]), ] - ) - return 1 # WIP + ws.append_row(cells) + + # Calcul largeur colonnes (actuellement pour feuille import multi seulement) + # Le facteur prend en compte la tailel du font (14) + font_size_factor = 1.25 + column_widths = { + ScoExcelSheet.i2col(idx): (len(str(cell.value)) + 2.0) * font_size_factor + for idx, cell in enumerate(cells) + } + # Force largeurs des colonnes noms/prénoms/groupes + column_widths["B"] = 26.0 # noms + column_widths["C"] = 26.0 # noms + column_widths["D"] = 26.0 # groupes + + return column_widths def _build_styles() -> dict: - """Déclare le styles excel""" + """Déclare les styles excel""" # bordures - side_thin = Side(border_style="thin", color=COLORS.BLACK.value) + side_thin = Side(border_style="thin", color=Color(rgb="666688")) border_top = Border(top=side_thin) + border_box = Border( + top=side_thin, left=side_thin, bottom=side_thin, right=side_thin + ) # fonds - fill_light_yellow = PatternFill( - patternType="solid", fgColor=COLORS.LIGHT_YELLOW.value - ) + fill_saisie_notes = PatternFill(patternType="solid", fgColor=Color(rgb="E3FED4")) # styles font_base = Font(name=FONT_NAME, size=12) @@ -179,22 +217,22 @@ def _build_styles() -> dict: }, "read-only": { # cells read-only "font": Font(name=FONT_NAME, color=COLORS.PURPLE.value), - "border": Border(right=side_thin), + "border": border_box, }, "dem": { "font": Font(name=FONT_NAME, color=COLORS.BROWN.value), - "border": border_top, + "border": border_box, }, "nom": { # style pour nom, prenom, groupe "font": font_base, - "border": border_top, + "border": border_box, }, "notes": { "alignment": Alignment(horizontal="right"), - "font": Font(name=FONT_NAME, bold=True), + "font": Font(name=FONT_NAME, bold=False), "number_format": FORMAT_GENERAL, - "fill": fill_light_yellow, - "border": border_top, + "fill": fill_saisie_notes, + "border": border_box, }, "comment": { "font": Font(name=FONT_NAME, size=9, color=COLORS.BLUE.value), @@ -204,9 +242,15 @@ def _build_styles() -> dict: def _insert_top_title( - ws, styles: dict, evaluation: Evaluation = None, description="" + ws, + styles: dict, + evaluation: Evaluation | None = None, + formsemestre: FormSemestre | None = None, + description="", ) -> int: - """Insère les lignes de titre de la feuille (suivies d'une ligne blanche) + """Insère les lignes de titre de la feuille (suivies d'une ligne blanche). + Si evaluation, indique son titre. + Si formsemestre, indique son titre. renvoie le nb de lignes insérées """ n = 0 @@ -219,42 +263,54 @@ def _insert_top_title( n += 1 # lignes d'instructions ws.append_single_cell_row( - "Saisir les notes dans la colonne E (cases jaunes)", + ( + "Saisir les notes dans la colonne E (cases vertes)" + if evaluation + else "Saisir les notes de chaque évaluation" + ), styles["explanation"], prefix=[""], ) ws.append_single_cell_row( - "Ne pas modifier les cases en mauve !", styles["explanation"], prefix=[""] + "Ne pas modifier les lignes et colonnes masquées (en mauve)!", + styles["explanation"], + prefix=[""], ) n += 2 # Nom du semestre - titre_annee = evaluation.moduleimpl.formsemestre.titre_annee() + titre_annee = ( + evaluation.moduleimpl.formsemestre.titre_annee() + if evaluation + else (formsemestre.titre_annee() if formsemestre else "") + ) 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}""" + if 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 - 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 @@ -300,7 +356,9 @@ def _insert_bottom_help(ws, styles: dict): ) -def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None): +def feuille_saisie_notes( + evaluation_id, group_ids: list[int] = None +): # TODO ré-écrire et passer dans notes.py """Vue: document Excel pour saisie notes dans l'évaluation et les groupes indiqués""" evaluation = Evaluation.get_evaluation(evaluation_id) group_ids = group_ids or [] @@ -360,6 +418,113 @@ def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None): return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) +def excel_feuille_import(formsemestre: FormSemestre) -> AnyStr: + """Génère feuille pour import toutes notes dans ce semestre, + avec une colonne par évaluation. + Return excel data + """ + evaluations = formsemestre.get_evaluations() + etudiants = formsemestre.get_inscrits(include_demdef=True, order=True) + rows = [{"etud": etud} for etud in etudiants] + # Liste les étudiants et leur note à chaque évaluation + for evaluation in evaluations: + notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id) + inscrits_module = {ins.etudid for ins in evaluation.moduleimpl.inscriptions} + for row in rows: + etud = row["etud"] + if not etud.id in inscrits_module: + note_str = "NI" # non inscrit à ce module + else: + val = notes_db.get(etud.id, {}).get("value", "") + # export numérique excel + note_str = scu.fmt_note(val, keep_numeric=True) + + row[evaluation.id] = note_str + # + return generate_excel_import_notes(evaluations, rows) + + +def generate_excel_import_notes( + evaluations: list[Evaluation], rows: list[dict] +) -> AnyStr: + """Génère la feuille excel pour l'import multi-évaluations. + On distingue ces feuille de celles utilisées pour une seule éval par la présence + de la valeur "MULTIEVAL" en tête de la colonne A (qui est invisible). + """ + ws = ScoExcelSheet("Import notes") + styles = _build_styles() + formsemestre: FormSemestre = ( + evaluations[0].moduleimpl.formsemestre if evaluations else None + ) + nb_lines_titles = _insert_top_title(ws, styles, formsemestre=formsemestre) + + # codes évaluations + ws.append_row( + [ + ws.make_cell(x, styles["read-only"]) + for x in [ + "MULTIEVAL", + "", + "", + "", + ] + ] + + [evaluation.id for evaluation in evaluations] + ) + column_widths = _insert_line_titles( + ws, + nb_lines_titles + 1, + nb_rows_in_table=len(rows), + evaluations=evaluations, + styles=styles, + multi_eval=True, + ) + if not formsemestre: # aucune évaluation + rows = [] + # etudiants + etuds_inscriptions = formsemestre.etuds_inscriptions + for row in rows: + etud: Identite = row["etud"] + st = styles["nom"] + match etuds_inscriptions[etud.id].etat: + case scu.INSCRIT: + groups = sco_groups.get_etud_groups(etud.id, formsemestre.id) + groupe_ou_etat = sco_groups.listgroups_abbrev(groups) + case scu.DEMISSION: + st = styles["dem"] + groupe_ou_etat = "DEM" + case scu.DEF: + groupe_ou_etat = "DEF" + st = styles["dem"] + case _: + groupe_ou_etat = "?" # état inconnu + ws.append_row( + [ + ws.make_cell("!" + str(etud.id), styles["read-only"]), + ws.make_cell(etud.nom_disp(), st), + ws.make_cell(etud.prenom_str, st), + ws.make_cell(groupe_ou_etat, st), + ] + + [ + ws.make_cell(row[evaluation.id], styles["notes"]) + for evaluation in evaluations + ] + ) + + # ligne blanche + ws.append_blank_row() + + # explication en bas + _insert_bottom_help(ws, styles) + + # Hide column A (codes étudiants) + ws.set_column_dimension_hidden("A", True) + # Hide row codes evaluations + ws.set_row_dimension_hidden(nb_lines_titles + 1, True) + + return ws.generate(column_widths=column_widths) + + def do_evaluation_upload_xls() -> tuple[bool, str]: """ Soumission d'un fichier XLS (evaluation_id, notefile) @@ -652,7 +817,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()): H.append( f"""
- Etape 1 : + Étape 1 :