############################################################################## # # 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 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, request, url_for from flask_login import current_user 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, 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", 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 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 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 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) return: ok: bool msg: message diagonistic à 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"]) try: if not rows: raise InvalidNoteValue() row_title_idx, evaluations, evaluations_col_idx = _get_sheet_evaluations( rows, evaluation=evaluation, diag=diag ) # --- get notes -> list (etudid, value) # ignore toutes les lignes ne commençant pas par ! notes_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) ) # -- Enregistre les notes de chaque évaluation messages_by_eval: dict[int, str] = {} etudids_with_decisions = set() for evaluation in evaluations: valid_notes = valid_notes_by_eval.get(evaluation.id) if not valid_notes: continue etudids_changed, nb_suppress, etudids_with_decisions_eval, messages = ( sco_saisie_notes.notes_add( current_user, evaluation.id, valid_notes_by_eval[evaluation.id], comment, ) ) etudids_with_decisions |= set(etudids_with_decisions_eval) msg = f"""
""" if messages: msg += f"""
Attention :
""" msg += """
""" 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, ) 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 ) if etudids_with_decisions: msg += """

Important: il y avait déjà des décisions de jury enregistrées, qui sont à revoir suite à cette modification !

""" return True, msg except InvalidNoteValue: if diag: msg = ( '" ) else: msg = '' return False, msg + "

(pas de notes modifiées)

" def _check_notes_evaluations( evaluations: list[Evaluation], notes_by_eval: dict[int, list[tuple[int, str]]], diag: list[str], ) -> tuple[dict[int, list[tuple[int, str]]], list[int], list[int]]: """Vérifie que les notes pour ces évaluations sont valides. Raise InvalidNoteValue et rempli diag si ce n'est pas le cas. Renvoie un dict donnant la liste des notes converties pour chaque évaluation. """ valid_notes_by_eval = {} etudids_without_notes_by_eval = {} etudids_absents_by_eval = {} for evaluation in evaluations: ( valid_notes_by_eval[evaluation.id], invalids, etudids_without_notes_by_eval[evaluation.id], etudids_absents_by_eval[evaluation.id], etudids_non_inscrits, ) = sco_saisie_notes.check_notes(notes_by_eval[evaluation.id], evaluation) if invalids: diag.append( f"Erreur: la feuille contient {len(invalids)} notes invalides

" ) if len(invalids) < 25: etudsnames = [ Identite.get_etud(etudid).nom_prenom() for etudid in invalids ] diag.append("Notes invalides pour: " + ", ".join(etudsnames)) else: diag.append("Notes invalides pour plus de 25 étudiants") raise InvalidNoteValue() if etudids_non_inscrits: diag.append( f"""Erreur: la feuille contient {len(etudids_non_inscrits) } étudiants non inscrits

""" ) if len(etudids_non_inscrits) < 25: diag.append( "etudid invalides (inexistants ou non inscrits): " + ", ".join(str(etudid) for etudid in etudids_non_inscrits) ) else: diag.append("etudid invalides sur plus de 25 lignes") raise InvalidNoteValue() return valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval def _read_notes_evaluations( row: list[str], etud: Identite, evaluations: list[Evaluation], notes_by_eval: dict[int, list[tuple[int, str]]], evaluations_col_idx: dict[int, int], ): """Lit les notes sur une ligne (étudiant etud). Ne vérifie pas la valeur de la note. """ for evaluation in evaluations: col_idx = evaluations_col_idx[evaluation.id] if len(row) > col_idx: val = row[col_idx].strip() else: val = "" # ligne courte: cellule vide notes_by_eval[evaluation.id].append((etud.id, val)) def _get_sheet_evaluations( rows: list[list[str]], evaluation: Evaluation | None = None, diag: list[str] = None ) -> tuple[int, list[Evaluation], dict[int, int]]: """ rows: les valeurs (str) des cellules de la feuille diag: liste dans laquelle accumuler les messages d'erreur evaluation (optionnel): l'évaluation que l'on cherche à remplir (pour feuille mono-évaluation) formsemestre (optionnel): le formsemestre dans lequel sont les évaluations à remplir formsemestre ou evaluation doivent être indiqués. Résultat: row_title_idx: l'indice (à partir de 0) de la ligne titre (après laquelle commencent les notes) evaluations: liste des évaluations à remplir evaluations_col_idx: { evaluation_id : indice de sa colonne dans la feuille } """ # -- search eval code: first cell in 1st column beginning by "!" eval_code = None for i, row in enumerate(rows): if not row: diag.append("Erreur: format invalide (ligne vide ?)") raise InvalidNoteValue() eval_code = row[0].strip() if eval_code.startswith("!"): break if not eval_code: diag.append("Erreur: format invalide ! (pas de ligne evaluation_id)") raise InvalidNoteValue() try: sheet_eval_id = int(eval_code[1:]) except ValueError: sheet_eval_id = None if sheet_eval_id != evaluation.id: diag.append( f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{ sheet_eval_id or ('non trouvé')}' != '{evaluation.id}')""" ) raise InvalidNoteValue() return i, [evaluation], {evaluation.id: 4} def saisie_notes_tableur(evaluation_id: int, group_ids=()): """Saisie des notes via un fichier Excel""" evaluation = Evaluation.query.get_or_404(evaluation_id) moduleimpl_id = evaluation.moduleimpl.id 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"""
Étape 1 :
""" ) H.append( """
Étape 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( f"""
Notes chargées !
{updiag[1]}
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("""""") if evaluation.moduleimpl.can_edit_notes(current_user, allow_ens=False): H.append( f"""
Autres opérations
Explications
  1. Etape 1:
    1. choisir le ou les groupes d'étudiants;
    2. télécharger le fichier Excel à remplir.
  2. 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.
""" ) H.append(html_sco_header.sco_footer()) return "\n".join(H)