############################################################################## # # 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. 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:
""" ) H.append(html_sco_header.sco_footer()) return "\n".join(H)