From 9a289d5956ae9748ae07f0a5aea95addb819afdd Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 28 Jun 2024 19:03:20 +0200 Subject: [PATCH] =?UTF-8?q?Saisie=20note=20excel:=20am=C3=A9liore=20feuill?= =?UTF-8?q?e=20et=20reorganise=20le=20code.=20+=20affichage=20date=20eval?= =?UTF-8?q?=20sans=20heures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/evaluations.py | 2 + app/scodoc/sco_excel.py | 275 ++++---------- app/scodoc/sco_saisie_excel.py | 655 +++++++++++++++++++++++++++++++++ app/scodoc/sco_saisie_notes.py | 520 +++----------------------- app/scodoc/sco_utils.py | 17 +- app/views/notes.py | 5 +- sco_version.py | 2 +- 7 files changed, 794 insertions(+), 682 deletions(-) create mode 100644 app/scodoc/sco_saisie_excel.py diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 98017e0e..4af3ab84 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -363,6 +363,8 @@ class Evaluation(models.ScoDocModel): return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" if self.date_debut.date() == self.date_fin.date(): # même jour if self.date_debut.time() == self.date_fin.time(): + if self.date_fin.time() == datetime.time(0, 0): + return f"le {self.date_debut.strftime('%d/%m/%Y')}" # sans heure return ( f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" ) diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index 08f1af9f..1bae0cdd 100644 --- a/app/scodoc/sco_excel.py +++ b/app/scodoc/sco_excel.py @@ -244,7 +244,10 @@ class ScoExcelSheet: return style @staticmethod - def i2col(idx): + def i2col(idx: int | str) -> str: + "traduit un index ou lettre de colonne en lettre de colonne" + if isinstance(idx, str): + return idx if idx < 26: # one letter key return chr(idx + 65) else: # two letters AA..ZZ @@ -252,19 +255,26 @@ class ScoExcelSheet: second = (idx % 26) + 65 return "" + chr(first) + chr(second) - def set_column_dimension_width(self, cle=None, value=21): - """Détermine la largeur d'une colonne. cle -- identifie la colonne ("A" "B", ... ou 0, 1, 2, ...) si None, - value donne la liste des largeurs de colonnes depuis A, B, C, ... value -- la dimension (unité : 7 pixels - comme affiché dans Excel) + def set_column_dimension_width(self, cle=None, value: int | str | list = 21): + """Détermine la largeur d'une colonne. + cle -- identifie la colonne (lettre ou indice à partir de 0), + Si cle is None, affecte toutes les colonnes. + + value est soit la liste des largeurs de colonnes [ (0, width0), (1, width1), ...] + soit la largeur de la colonne indiquée par cle, soit "auto". + Largeurs en unité : 7 pixels comme affiché dans Excel) + ou value == "auto", ajuste la largeur au contenu """ if cle is None: - for i, val in enumerate(value): - self.ws.column_dimensions[self.i2col(i)].width = val - # No keys: value is a list of widths - elif isinstance(cle, str): # accepts set_column_with("D", ...) - self.ws.column_dimensions[cle].width = value + cols_widths = enumerate(value) else: - self.ws.column_dimensions[self.i2col(cle)].width = value + cols_widths = [(cle, value)] + + for idx, width in cols_widths: + if width == "auto": + self.adjust_column_widths(column_letter=self.i2col(idx)) + else: + self.ws.column_dimensions[self.i2col(idx)].width = width def set_row_dimension_height(self, cle=None, value=21): """Détermine la hauteur d'une ligne. cle -- identifie la ligne (1, 2, ...) si None, @@ -291,10 +301,48 @@ class ScoExcelSheet: """ self.ws.column_dimensions[cle].hidden = value - def set_auto_filter(self, range): - self.auto_filter = range + def set_auto_filter(self, filter_range): + "met en place un auto-filter excel: le range désigne les cellules de titres" + self.auto_filter = filter_range - def make_cell(self, value: any = None, style=None, comment=None): + def adjust_column_widths( + self, column_letter=None, min_row=None, max_row=None, min_col=None, max_col=None + ): + """Adjust columns widths to fit their content. + If column_letter, adjust only this column, else adjust all. + (min_row, max_row, min_col, max_col) can be used to restrinct the area to consider + while determining the widths. + """ + # Create a dictionary to store the maximum width of each column + col_widths = {} + + if column_letter is None: + # Iterate over each row and cell in the worksheet + for row in self.ws.iter_rows( + min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col + ): + for cell in row: + # Get the length of the cell value (converted to string) + cell_value = str(cell.value) + # Update the maximum width for the column + col_widths[cell.column_letter] = max( + col_widths.get(cell.column_letter, 0), len(cell_value) + ) + 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) + col_widths[cell.column_letter] = max( + col_widths.get(cell.column_letter, 0), len(cell_value) + ) + + # Set the column widths based on the maximum length found + 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): """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é @@ -307,9 +355,8 @@ class ScoExcelSheet: elif value is False: value = 0 elif isinstance(value, datetime.datetime): - value = value.replace( - tzinfo=None - ) # make date naive (cf https://openpyxl.readthedocs.io/en/latest/datetime.html#timezones) + # make date naive (cf https://openpyxl.readthedocs.io/en/latest/datetime.html#timezones) + value = value.replace(tzinfo=None) # création de la cellule cell = WriteOnlyCell(self.ws, value) @@ -358,13 +405,14 @@ class ScoExcelSheet: for value, comment in zip(values, comments) ] - def append_single_cell_row(self, value: any, style=None): + def append_single_cell_row(self, value: any, style=None, prefix=None): """construit une ligne composée d'une seule cellule et l'ajoute à la feuille. mêmes paramètres que make_cell: value -- contenu de la cellule (texte ou numérique) style -- style par défaut de la feuille si non spécifié + prefix -- cellules ajoutées au début de la ligne """ - self.append_row([self.make_cell(value, style)]) + self.append_row((prefix or []) + [self.make_cell(value, style)]) def append_blank_row(self): """construit une ligne vide et l'ajoute à la feuille.""" @@ -386,14 +434,21 @@ class ScoExcelSheet: for row in self.rows: self.ws.append(row) - def generate(self): + def generate(self, column_widths=None): """génération d'un classeur mono-feuille""" - # this method makes sense only if it is a standalone worksheet (else call workbook.generate() + # this method makes sense for standalone worksheet (else call workbook.generate()) if self.wb is None: # embeded sheet raise ScoValueError("can't generate a single sheet from a ScoWorkbook") - # construction d'un flux (https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream) + # construction d'un flux + # https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream self.prepare() + + # largeur des colonnes + if column_widths: + for k, v in column_widths.items(): + self.set_column_dimension_width(k, v) + if self.auto_filter is not None: self.ws.auto_filter.ref = self.auto_filter with NamedTemporaryFile() as tmp: @@ -446,182 +501,6 @@ def excel_simple_table( return ws.generate() -def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, lines): - """Genere feuille excel pour saisie des notes. - E: evaluation (dict) - lines: liste de tuples - (etudid, nom, prenom, etat, groupe, val, explanation) - """ - sheet_name = "Saisie notes" - ws = ScoExcelSheet(sheet_name) - - # ajuste largeurs colonnes (unite inconnue, empirique) - ws.set_column_dimension_width("A", 11.0 / 7) # codes - # ws.set_column_dimension_hidden("A", True) # codes - ws.set_column_dimension_width("B", 164.00 / 7) # noms - ws.set_column_dimension_width("C", 109.0 / 7) # prenoms - ws.set_column_dimension_width("D", 164.0 / 7) # groupes - ws.set_column_dimension_width("E", 115.0 / 7) # notes - ws.set_column_dimension_width("F", 355.0 / 7) # remarques - ws.set_column_dimension_width("G", 72.0 / 7) # colonne NIP - ws.set_column_dimension_hidden("G", True) # colonne NIP cachée - - # fontes - font_base = Font(name="Arial", size=12) - font_bold = Font(name="Arial", bold=True) - font_italic = Font(name="Arial", size=12, italic=True, color=COLORS.RED.value) - font_titre = Font(name="Arial", bold=True, size=14) - font_purple = Font(name="Arial", color=COLORS.PURPLE.value) - font_brown = Font(name="Arial", color=COLORS.BROWN.value) - font_blue = Font(name="Arial", 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 - style = {"font": font_base} - style_titres = {"font": font_titre} - style_expl = {"font": font_italic} - - style_ro = { # cells read-only - "font": font_purple, - "border": border_right, - } - style_dem = { - "font": font_brown, - "border": border_top, - } - style_nom = { # style pour nom, prenom, groupe - "font": font_base, - "border": border_top, - } - style_notes = { - "font": font_bold, - "number_format": FORMAT_GENERAL, - "fill": fill_light_yellow, - "border": border_top, - } - style_comment = { - "font": font_blue, - "border": border_top, - } - - # filtre - filter_top = 8 - filter_bottom = 8 + len(lines) - filter_left = "A" - filter_right = "G" - ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}") - - # ligne de titres - ws.append_single_cell_row( - "Feuille saisie note (à enregistrer au format excel)", style_titres - ) - # lignes d'instructions - ws.append_single_cell_row( - "Saisir les notes dans la colonne E (cases jaunes)", style_expl - ) - ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl) - # Nom du semestre - ws.append_single_cell_row(scu.unescape_html(titreannee), style_titres) - # description evaluation - ws.append_single_cell_row(scu.unescape_html(description), style_titres) - ws.append_single_cell_row( - f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})", - style, - ) - # ligne blanche - ws.append_blank_row() - # code et titres colonnes - ws.append_row( - [ - ws.make_cell("!%s" % evaluation.id, style_ro), - ws.make_cell("Nom", style_titres), - ws.make_cell("Prénom", style_titres), - ws.make_cell("Groupe", style_titres), - ws.make_cell("Note sur %g" % (evaluation.note_max or 0.0), style_titres), - ws.make_cell("Remarque", style_titres), - ws.make_cell("NIP", style_titres), - ] - ) - - # etudiants - for line in lines: - st = style_nom - if line[3] != "I": - st = style_dem - if line[3] == "D": # demissionnaire - s = "DEM" - else: - s = line[3] # etat autre - else: - s = line[4] # groupes TD/TP/... - try: - val = float(line[5]) - except ValueError: - val = line[5] - ws.append_row( - [ - ws.make_cell("!" + line[0], style_ro), # code - ws.make_cell(line[1], st), - ws.make_cell(line[2], st), - ws.make_cell(s, st), - ws.make_cell(val, style_notes), # note - ws.make_cell(line[6], style_comment), # comment - ws.make_cell(line[7], style_ro), # NIP - ] - ) - - # ligne blanche - ws.append_blank_row() - - # explication en bas - ws.append_row([None, ws.make_cell("Code notes", style_titres)]) - ws.append_row( - [ - None, - ws.make_cell("ABS", style_expl), - ws.make_cell("absent (0)", style_expl), - ] - ) - ws.append_row( - [ - None, - ws.make_cell("EXC", style_expl), - ws.make_cell("pas prise en compte", style_expl), - ] - ) - ws.append_row( - [ - None, - ws.make_cell("ATT", style_expl), - ws.make_cell("en attente", style_expl), - ] - ) - ws.append_row( - [ - None, - ws.make_cell("SUPR", style_expl), - ws.make_cell("pour supprimer note déjà entrée", style_expl), - ] - ) - ws.append_row( - [ - None, - ws.make_cell("", style_expl), - ws.make_cell("cellule vide -> note non modifiée", style_expl), - ] - ) - return ws.generate() - - def excel_bytes_to_list(bytes_content): try: filelike = io.BytesIO(bytes_content) diff --git a/app/scodoc/sco_saisie_excel.py b/app/scodoc/sco_saisie_excel.py new file mode 100644 index 00000000..4982a6ae --- /dev/null +++ b/app/scodoc/sco_saisie_excel.py @@ -0,0 +1,655 @@ +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Fichier excel de saisie des notes +""" + +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_login import current_user + +from app.models import Evaluation, Identite, Module, ScolarNews +from app.scodoc.sco_excel import COLORS, ScoExcelSheet +from app.scodoc import ( + html_sco_header, + sco_evaluations, + sco_excel, + sco_groups, + sco_groups_view, + sco_saisie_notes, + sco_users, +) +from app.scodoc.sco_exceptions import AccessDenied, InvalidNoteValue +import app.scodoc.sco_utils as scu +from app.scodoc.TrivialFormulator import TrivialFormulator + +FONT_NAME = "Arial" + + +def excel_feuille_saisie( + evaluation: "Evaluation", titreannee, description, rows: list[dict] +): + """Genere feuille excel pour saisie des notes. + E: evaluation (dict) + lines: liste de tuples + (etudid, nom, prenom, etat, groupe, val, explanation) + """ + sheet_name = "Saisie notes" + ws = ScoExcelSheet(sheet_name) + + # 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"]), + ] + ) + + # etudiants + for row in rows: + st = styles["nom"] + if row["etat"] != scu.INSCRIT: + st = styles["dem"] + if row["etat"] == scu.DEMISSION: # demissionnaire + groupe_ou_etat = "DEM" + else: + groupe_ou_etat = row["etat"] # etat autre + else: + groupe_ou_etat = row["groupes"] # groupes TD/TP/... + try: + note_str = float(row["note"]) # export numérique excel + except ValueError: + note_str = row["note"] # "ABS", ... + ws.append_row( + [ + ws.make_cell("!" + row["etudid"], styles["read-only"]), + ws.make_cell(row["nom"], st), + ws.make_cell(row["prenom"], st), + ws.make_cell(groupe_ou_etat, st), + ws.make_cell(note_str, styles["notes"]), # note + ws.make_cell(row["explanation"], styles["comment"]), # comment + ws.make_cell(row["code_nip"], styles["read-only"]), + ] + ) + + # ligne blanche + ws.append_blank_row() + + # explication en bas + _insert_bottom_help(ws, styles) + ws.set_column_dimension_hidden("A", True) # colonne etudid cachée + ws.set_column_dimension_hidden("G", True) # colonne NIP cachée + + return ws.generate( + column_widths={ + "A": 11.0 / 7, # codes + "B": 164.00 / 7, # noms + "C": 109.0 / 7, # prenoms + "D": "auto", # groupes + "E": 115.0 / 7, # notes + "F": 355.0 / 7, # remarques + "G": 72.0 / 7, # colonne NIP + } + ) + + +def _insert_bottom_help(ws, styles: dict): + ws.append_row([None, ws.make_cell("Code notes", styles["titres"])]) + ws.append_row( + [ + None, + ws.make_cell("ABS", styles["explanation"]), + ws.make_cell("absent (0)", styles["explanation"]), + ] + ) + ws.append_row( + [ + None, + ws.make_cell("EXC", styles["explanation"]), + ws.make_cell("pas prise en compte", styles["explanation"]), + ] + ) + ws.append_row( + [ + None, + ws.make_cell("ATT", styles["explanation"]), + ws.make_cell("en attente", styles["explanation"]), + ] + ) + ws.append_row( + [ + None, + ws.make_cell("SUPR", styles["explanation"]), + ws.make_cell("pour supprimer note déjà entrée", styles["explanation"]), + ] + ) + ws.append_row( + [ + None, + ws.make_cell("", styles["explanation"]), + ws.make_cell("cellule vide -> note non modifiée", styles["explanation"]), + ] + ) + + +def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None): + """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 [] + 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, + formsemestre_id=formsemestre.id, + select_all_when_unspecified=True, + etat=None, + ) + groups = sco_groups.listgroups(groups_infos.group_ids) + gr_title_filename = sco_groups.listgroups_filename(groups) + if None in [g["group_name"] for g in groups]: # tous les etudiants + getallstudents = True + gr_title_filename = "tous" + else: + getallstudents = False + etudids = [ + x[0] + for x in sco_groups.do_evaluation_listeetuds_groups( + evaluation_id, groups, getallstudents=getallstudents, include_demdef=True + ) + ] + + # une liste de liste de chaines: lignes de la feuille de calcul + rows = [] + etuds = sco_saisie_notes.get_sorted_etuds_notes( + evaluation, etudids, formsemestre.id + ) + for e in etuds: + etudid = e["etudid"] + groups = sco_groups.get_etud_groups(etudid, formsemestre.id) + grc = sco_groups.listgroups_abbrev(groups) + rows.append( + { + "etudid": str(etudid), + "code_nip": e["code_nip"], + "explanation": e["explanation"], + "nom": e.get("nom_disp", "") or e.get("nom_usuel", "") or e["nom"], + "prenom": e["prenom"].lower().capitalize(), + "etat": e["inscr"]["etat"], + "groupes": grc, + "note": e["val"], + } + ) + + filename = f"notes_{eval_name}_{gr_title_filename}" + xls = excel_feuille_saisie( + evaluation, formsemestre.titre_annee(), description, rows=rows + ) + return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) + + +def do_evaluation_upload_xls(): + """ + Soumission d'un fichier XLS (evaluation_id, notefile) + """ + args = scu.get_request_args() + evaluation_id = int(args["evaluation_id"]) + comment = args["comment"] + evaluation = Evaluation.get_evaluation(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"]) + 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)") + 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() + # --- 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() + + etudids_changed, nb_suppress, etudids_with_decisions, messages = ( + sco_saisie_notes.notes_add( + current_user, evaluation_id, valid_notes, comment + ) + ) + # 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, + ) + ScolarNews.add( + typ=ScolarNews.NEWS_NOTE, + obj=evaluation.moduleimpl_id, + text=f"""Chargement notes dans { + module.titre or module.code}""", + url=status_url, + 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 !

+ """ + return 1, msg + + except InvalidNoteValue: + if diag: + msg = ( + '" + ) + else: + msg = '' + return 0, msg + "

(pas de notes modifiées)

" + + +def saisie_notes_tableur(evaluation_id, group_ids=()): + """Saisie des notes via un fichier Excel""" + evaluation = Evaluation.query.get_or_404(evaluation_id) + moduleimpl_id = evaluation.moduleimpl.id + formsemestre_id = evaluation.moduleimpl.formsemestre_id + if not evaluation.moduleimpl.can_edit_notes(current_user): + return ( + html_sco_header.sco_header() + + f""" +

Modification des notes impossible pour {current_user.user_name}

+

(vérifiez que le semestre n'est pas verrouillé et que vous + avez l'autorisation d'effectuer cette opération) +

+

Continuer

+ """ + + html_sco_header.sco_footer() + ) + + page_title = "Saisie des notes" + ( + f""" de {evaluation.description}""" if evaluation.description else "" + ) + + # Informations sur les groupes à afficher: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids=group_ids, + formsemestre_id=formsemestre_id, + select_all_when_unspecified=True, + etat=None, + ) + + H = [ + html_sco_header.sco_header( + page_title=page_title, + javascripts=sco_groups_view.JAVASCRIPTS, + cssstyles=sco_groups_view.CSSSTYLES, + init_qtip=True, + ), + sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), + """Saisie des notes par fichier""", + ] + + # Menu choix groupe: + H.append("""
""") + H.append(sco_groups_view.form_groups_choice(groups_infos)) + H.append("
") + + H.append( + f"""
+ Etape 1 : + +
+
+ +
+ """ + ) + + H.append( + """
+ Etape 2 : chargement d'un fichier de notes""" # ' + ) + + nf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), + ( + "notefile", + {"input_type": "file", "title": "Fichier de note (.xls)", "size": 44}, + ), + ( + "comment", + { + "size": 44, + "title": "Commentaire", + "explanation": "(la colonne remarque du fichier excel est ignorée)", + }, + ), + ), + formid="notesfile", + submitlabel="Télécharger", + ) + if nf[0] == 0: + H.append( + """

Le fichier doit être un fichier tableur obtenu via + l'étape 1 ci-dessus, puis complété et enregistré au format Excel. +

""" + ) + H.append(nf[1]) + elif nf[0] == -1: + H.append("

Annulation

") + elif nf[0] == 1: + updiag = do_evaluation_upload_xls() + if updiag[0]: + H.append(updiag[1]) + H.append( + f"""

Notes chargées.    + + Revenir au tableau de bord du module +     + Charger un autre fichier de notes +     + Formulaire de saisie des notes +

""" + ) + else: + H.append( + f""" +

Notes non chargées !

+ {updiag[1]} +

+ Reprendre +

+ """ + ) + + # + H.append("""

Autres opérations

+ +

Explications

+
    +
  1. Etape 1: +
    1. choisir le ou les groupes d'étudiants;
    2. +
    3. télécharger le fichier Excel à remplir.
    4. +
    +
  2. +
  3. Etape 2 (cadre vert): Indiquer le fichier Excel + téléchargé à l'étape 1 et dans lequel on a saisi des notes. + Remarques: +
      +
    • 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; +
    • +
    • seules les valeurs des notes modifiées sont prises en compte; +
    • +
    • seules les notes sont extraites du fichier Excel; +
    • +
    • on peut optionnellement ajouter un commentaire (type "copies corrigées + par Dupont", ou "Modif. suite à contestation") dans la case "Commentaire". +
    • +
    • le fichier Excel doit impérativement être celui chargé à l'étape 1 + pour cette évaluation. Il n'est pas possible d'utiliser une liste d'appel + ou autre document Excel téléchargé d'une autre page. +
    • +
    +
  4. +
+""" + ) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index 78bc9783..6124410d 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -1,6 +1,3 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - ############################################################################## # # Gestion scolarite IUT @@ -34,7 +31,7 @@ import time import flask -from flask import g, url_for, request +from flask import g, url_for from flask_login import current_user from flask_sqlalchemy.query import Query import psycopg2 @@ -55,25 +52,23 @@ from app.models.etudiants import Identite from app.scodoc.sco_exceptions import ( AccessDenied, - InvalidNoteValue, NoteProcessError, ScoException, ScoInvalidParamError, ScoValueError, ) -from app.scodoc import html_sco_header, sco_users +from app.scodoc import html_sco_header from app.scodoc import htmlutils from app.scodoc import sco_cache from app.scodoc import sco_etud from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluations -from app.scodoc import sco_excel from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_groups_view from app.scodoc import sco_undo_notes import app.scodoc.notesdb as ndb -from app.scodoc.TrivialFormulator import TrivialFormulator, TF +from app.scodoc.TrivialFormulator import TF import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import ModuleType @@ -116,25 +111,7 @@ def convert_note_from_string( return note_value, invalid -def _display_note(val): - """Convert note from DB to viewable string. - Utilisé seulement pour I/O vers formulaires (sans perte de precision) - (Utiliser fmt_note pour les affichages) - """ - if val is None: - val = "ABS" - elif val == scu.NOTES_NEUTRALISE: - val = "EXC" # excuse, note neutralise - elif val == scu.NOTES_ATTENTE: - val = "ATT" # attente, note neutralise - elif val == scu.NOTES_SUPPRESS: - val = "SUPR" - else: - val = "%g" % val - return val - - -def _check_notes(notes: list[(int, float | str)], evaluation: Evaluation): +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) @@ -193,137 +170,6 @@ def _check_notes(notes: list[(int, float | str)], evaluation: Evaluation): ) -def do_evaluation_upload_xls(): - """ - Soumission d'un fichier XLS (evaluation_id, notefile) - """ - vals = scu.get_request_args() - evaluation_id = int(vals["evaluation_id"]) - comment = vals["comment"] - evaluation: Evaluation = Evaluation.query.get_or_404(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(vals["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)") - 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() - # --- 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, _ = _check_notes( - notes, evaluation - ) - if invalids: - diag.append( - f"Erreur: la feuille contient {len(invalids)} notes invalides

" - ) - if len(invalids) < 25: - etudsnames = [ - sco_etud.get_etud_info(etudid=etudid, filled=True)[0]["nomprenom"] - for etudid in invalids - ] - diag.append("Notes invalides pour: " + ", ".join(etudsnames)) - raise InvalidNoteValue() - else: - etudids_changed, nb_suppress, etudids_with_decisions, messages = notes_add( - current_user, evaluation_id, valid_notes, comment - ) - # 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, - ) - ScolarNews.add( - typ=ScolarNews.NEWS_NOTE, - obj=evaluation.moduleimpl_id, - text=f"""Chargement notes dans { - module.titre or module.code}""", - url=status_url, - 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 !

- """ - return 1, msg - - except InvalidNoteValue: - if diag: - msg = ( - '" - ) - else: - msg = '' - return 0, msg + "

(pas de notes modifiées)

" - - def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -> bool: """Enregistre la note d'un seul étudiant value: valeur externe (float ou str) @@ -331,10 +177,10 @@ def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) - if not evaluation.moduleimpl.can_edit_notes(current_user): raise AccessDenied(f"Modification des notes impossible pour {current_user}") # Convert and check value - L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation) + notes, invalids, _, _, _ = check_notes([(etud.id, value)], evaluation) if len(invalids) == 0: etudids_changed, _, _, _ = notes_add( - current_user, evaluation.id, L, "Initialisation notes" + current_user, evaluation.id, notes, "Initialisation notes" ) if len(etudids_changed) == 1: return True @@ -371,7 +217,7 @@ def do_evaluation_set_missing( if etudid not in notes_db: # pas de note notes.append((etudid, value)) # Convert and check values - valid_notes, invalids, _, _, _ = _check_notes(notes, evaluation) + valid_notes, invalids, _, _, _ = check_notes(notes, evaluation) dest_url = url_for( "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id ) @@ -615,7 +461,7 @@ def notes_add( etudids_changed.append(etudid) if res.etud_has_decision(etudid, include_rcues=False): etudids_with_decision.append(etudid) - except Exception as exc: + except NotImplementedError as exc: # XXX log("*** exception in notes_add") if do_it: cnx.rollback() # abort @@ -675,12 +521,13 @@ def _record_note( else: # il y a deja une note oldval = notes_db[etudid]["value"] - if type(value) != type(oldval): - changed = True - elif isinstance(value, float) and (abs(value - oldval) > scu.NOTES_PRECISION): - changed = True - elif value != oldval: - changed = True + changed = ( + (not isinstance(value, type(oldval))) + or ( + isinstance(value, float) and (abs(value - oldval) > scu.NOTES_PRECISION) + ) + or value != oldval + ) if changed: # recopie l'ancienne note dans notes_notes_log, puis update if do_it: @@ -731,284 +578,7 @@ def _record_note( return changed, suppressed -def saisie_notes_tableur(evaluation_id, group_ids=()): - """Saisie des notes via un fichier Excel""" - evaluation = Evaluation.query.get_or_404(evaluation_id) - moduleimpl_id = evaluation.moduleimpl.id - formsemestre_id = evaluation.moduleimpl.formsemestre_id - if not evaluation.moduleimpl.can_edit_notes(current_user): - return ( - html_sco_header.sco_header() - + f""" -

Modification des notes impossible pour {current_user.user_name}

-

(vérifiez que le semestre n'est pas verrouillé et que vous - avez l'autorisation d'effectuer cette opération) -

-

Continuer

- """ - + html_sco_header.sco_footer() - ) - - page_title = "Saisie des notes" + ( - f""" de {evaluation.description}""" if evaluation.description else "" - ) - - # Informations sur les groupes à afficher: - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids=group_ids, - formsemestre_id=formsemestre_id, - select_all_when_unspecified=True, - etat=None, - ) - - H = [ - html_sco_header.sco_header( - page_title=page_title, - javascripts=sco_groups_view.JAVASCRIPTS, - cssstyles=sco_groups_view.CSSSTYLES, - init_qtip=True, - ), - sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), - """Saisie des notes par fichier""", - ] - - # Menu choix groupe: - H.append("""
""") - H.append(sco_groups_view.form_groups_choice(groups_infos)) - H.append("
") - - H.append( - f"""
- Etape 1 : - -
-
- -
- """ - ) - - H.append( - """
- Etape 2 : chargement d'un fichier de notes""" # ' - ) - - nf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), - ( - "notefile", - {"input_type": "file", "title": "Fichier de note (.xls)", "size": 44}, - ), - ( - "comment", - { - "size": 44, - "title": "Commentaire", - "explanation": "(la colonne remarque du fichier excel est ignorée)", - }, - ), - ), - formid="notesfile", - submitlabel="Télécharger", - ) - if nf[0] == 0: - H.append( - """

Le fichier doit être un fichier tableur obtenu via - l'étape 1 ci-dessus, puis complété et enregistré au format Excel. -

""" - ) - H.append(nf[1]) - elif nf[0] == -1: - H.append("

Annulation

") - elif nf[0] == 1: - updiag = do_evaluation_upload_xls() - if updiag[0]: - H.append(updiag[1]) - H.append( - f"""

Notes chargées.    - - Revenir au tableau de bord du module -     - Charger un autre fichier de notes -     - Formulaire de saisie des notes -

""" - ) - else: - H.append( - f""" -

Notes non chargées !

- {updiag[1]} -

- Reprendre -

- """ - ) - - # - H.append("""

Autres opérations

- -

Explications

-
    -
  1. Etape 1: -
    1. choisir le ou les groupes d'étudiants;
    2. -
    3. télécharger le fichier Excel à remplir.
    4. -
    -
  2. -
  3. Etape 2 (cadre vert): Indiquer le fichier Excel - téléchargé à l'étape 1 et dans lequel on a saisi des notes. - Remarques: -
      -
    • 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; -
    • -
    • seules les valeurs des notes modifiées sont prises en compte; -
    • -
    • seules les notes sont extraites du fichier Excel; -
    • -
    • on peut optionnellement ajouter un commentaire (type "copies corrigées - par Dupont", ou "Modif. suite à contestation") dans la case "Commentaire". -
    • -
    • le fichier Excel doit impérativement être celui chargé à l'étape 1 - pour cette évaluation. Il n'est pas possible d'utiliser une liste d'appel - ou autre document Excel téléchargé d'une autre page. -
    • -
    -
  4. -
-""" - ) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None): - """Document Excel pour saisie notes dans l'évaluation et les groupes indiqués""" - evaluation: Evaluation = db.session.get(Evaluation, evaluation_id) - if not evaluation: - raise ScoValueError("invalid evaluation_id") - 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, - formsemestre_id=formsemestre.id, - select_all_when_unspecified=True, - etat=None, - ) - groups = sco_groups.listgroups(groups_infos.group_ids) - gr_title_filename = sco_groups.listgroups_filename(groups) - if None in [g["group_name"] for g in groups]: # tous les etudiants - getallstudents = True - gr_title_filename = "tous" - else: - getallstudents = False - etudids = [ - x[0] - for x in sco_groups.do_evaluation_listeetuds_groups( - evaluation_id, groups, getallstudents=getallstudents, include_demdef=True - ) - ] - - # une liste de liste de chaines: lignes de la feuille de calcul - rows = [] - etuds = _get_sorted_etuds(evaluation, etudids, formsemestre.id) - for e in etuds: - etudid = e["etudid"] - groups = sco_groups.get_etud_groups(etudid, formsemestre.id) - grc = sco_groups.listgroups_abbrev(groups) - rows.append( - [ - str(etudid), - e.get("nom_disp", "") or e.get("nom_usuel", "") or e["nom"], - e["prenom"].lower().capitalize(), - e["inscr"]["etat"], - grc, - e["val"], - e["explanation"], - e["code_nip"], - ] - ) - - filename = f"notes_{eval_name}_{gr_title_filename}" - xls = sco_excel.excel_feuille_saisie( - evaluation, formsemestre.titre_annee(), description, lines=rows - ) - return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) - - -# ----------------------------- # Nouveau formulaire saisie notes (2016) - - def saisie_notes(evaluation_id: int, group_ids: list = None): """Formulaire saisie notes d'une évaluation pour un groupe""" if not isinstance(evaluation_id, int): @@ -1095,9 +665,11 @@ def saisie_notes(evaluation_id: int, group_ids: list = None): ) H.append( """ - - - + + + + +