############################################################################## # # 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 ## 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 from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side from openpyxl.styles.numbers import FORMAT_GENERAL from flask import g, render_template, request, url_for from flask_login import current_user 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, 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 from app.views import ScoData FONT_NAME = "Arial" 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. """ ws = ScoExcelSheet("Saisie notes") styles = _build_styles() nb_lines_titles = _insert_top_title(ws, styles, evaluation=evaluation) _insert_line_titles( ws, nb_lines_titles, nb_rows_in_table=len(rows), evaluations=[evaluation], styles=styles, ) # 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) # 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 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_line_titles( ws, current_line, nb_rows_in_table: int = 0, evaluations: list[Evaluation] = None, styles: dict = None, 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) multi_eval: si vrai, titres pour plusieurs évaluations (feuille import semestre) Return dict giving (title) column widths """ # 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 = right_column ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}") # Code et titres colonnes 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"]), 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"]), ] ws.append_row(cells) # Calcul largeur colonnes (actuellement pour feuille import multi seulement) # 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 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 les styles excel""" # bordures 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_saisie_notes = PatternFill(patternType="solid", fgColor=Color(rgb="E3FED4")) # styles font_base = Font(name=FONT_NAME, size=12) return { "base": {"font": font_base}, "titres": {"font": Font(name=FONT_NAME, bold=True, size=14)}, "explanation": { "font": Font(name=FONT_NAME, size=12, italic=True, color=COLORS.RED.value) }, "read-only": { # cells read-only "font": Font(name=FONT_NAME, color=COLORS.PURPLE.value), "border": border_box, }, "dem": { "font": Font(name=FONT_NAME, color=COLORS.BROWN.value), "border": border_box, }, "nom": { # style pour nom, prenom, groupe "font": font_base, "border": border_box, }, "notes": { "alignment": Alignment(horizontal="right"), "font": Font(name=FONT_NAME, bold=False), "number_format": FORMAT_GENERAL, "fill": fill_saisie_notes, "border": border_box, }, "comment": { "font": Font(name=FONT_NAME, size=9, color=COLORS.BLUE.value), "border": border_top, }, } def _insert_top_title( ws, styles: dict, evaluation: Evaluation | None = None, formsemestre: FormSemestre | None = None, description="", ) -> int: """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 # ligne de titres (utilise prefix pour se placer à partir de la colonne B) ws.append_single_cell_row( "Feuille saisie note (à enregistrer au format excel)", styles["titres"], prefix=[""], ) n += 1 # lignes d'instructions ws.append_single_cell_row( ( "Saisir les notes dans la colonne E (cases vertes)" if evaluation else "Saisir les notes de chaque évaluation" ), styles["explanation"], prefix=[""], ) ws.append_single_cell_row( "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() 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 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 # ligne blanche ws.append_blank_row() n += 1 return n def _insert_bottom_help(ws, styles: dict): ws.append_row([None, ws.make_cell("Code notes", styles["titres"])]) ws.append_row( [ 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 ): # 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 [] modimpl = evaluation.moduleimpl formsemestre = modimpl.formsemestre if evaluation.date_debut: indication_date = evaluation.date_debut.date().isoformat() else: indication_date = scu.sanitize_filename(evaluation.description)[:12] 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"], } ) eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}" filename = f"notes_{eval_name}_{gr_title_filename}" xls = excel_feuille_saisie(evaluation, rows=rows) 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 if formsemestre else {} 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_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 diagnostic à affciher """ 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, formsemestre=formsemestre, diag=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 with sco_cache.DeferredSemCacheManager(): messages_by_eval, etudids_with_decisions = _record_notes_evaluations( evaluations, notes_by_eval, comment, diag, rows=rows ) # -- News 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=obj_id, text=f"""Chargement notes dans {modules_str}""", url=status_url, max_frequency=30 * 60, # 30 minutes ) msg = "
Important: Il y avait déjà des décisions de jury enregistrées, qui sont à revoir suite à cette modification !
""" + msg ) return True, msg except InvalidNoteValue: if diag: msg = ( '(pas de notes modifiées)
" 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"""(vérifiez que le semestre n'est pas verrouillé et que vous avez l'autorisation d'effectuer cette opération)
""" + 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(" |
Le fichier doit être un fichier tableur obtenu via l'étape 1 ci-dessus, puis complété et enregistré au format Excel.
""" ) H.append(tf[1]) elif tf[0] == -1: H.append("Annulation
") 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"""Notes non chargées !
{diagnostic_msg} """ ) # H.append("""""") if evaluation.moduleimpl.can_edit_notes(current_user, allow_ens=False): H.append( f"""