ScoDoc/app/scodoc/sco_saisie_excel.py

656 lines
23 KiB
Python

##############################################################################
#
# 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})<br>{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</p>"
)
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 <a href="{status_url}">{
module.titre or module.code}</a>""",
url=status_url,
max_frequency=30 * 60, # 30 minutes
)
msg = f"""<p>
{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes,
{len(absents)} absents, {nb_suppress} note supprimées)
</p>"""
if messages:
msg += f"""<div class="warning">Attention&nbsp;:
<ul>
<li>{
'</li><li>'.join(messages)
}
</li>
</ul>
</div>"""
if etudids_with_decisions:
msg += """<p class="warning">Important: il y avait déjà des décisions de jury
enregistrées, qui sont peut-être à revoir suite à cette modification !</p>
"""
return 1, msg
except InvalidNoteValue:
if diag:
msg = (
'<ul class="tf-msg"><li class="tf_msg">'
+ '</li><li class="tf_msg">'.join(diag)
+ "</li></ul>"
)
else:
msg = '<ul class="tf-msg"><li class="tf_msg">Une erreur est survenue</li></ul>'
return 0, msg + "<p>(pas de notes modifiées)</p>"
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"""
<h2>Modification des notes impossible pour {current_user.user_name}</h2>
<p>(vérifiez que le semestre n'est pas verrouillé et que vous
avez l'autorisation d'effectuer cette opération)
</p>
<p><a class="stdlink" href="{
url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept,
moduleimpl_id=moduleimpl_id)
}">Continuer</a></p>
"""
+ 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),
"""<span class="eval_title">Saisie des notes par fichier</span>""",
]
# Menu choix groupe:
H.append("""<div id="group-tabs"><table><tr><td>""")
H.append(sco_groups_view.form_groups_choice(groups_infos))
H.append("</td></tr></table></div>")
H.append(
f"""<div class="saisienote_etape1">
<span class="titredivsaisienote">Etape 1 : </span>
<ul>
<li><a class="stdlink" href="feuille_saisie_notes?evaluation_id={evaluation_id}&{
groups_infos.groups_query_args}"
id="lnk_feuille_saisie">obtenir le fichier tableur à remplir</a>
</li>
<li>ou <a class="stdlink" href="{url_for("notes.saisie_notes",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id)
}">aller au formulaire de saisie</a></li>
</ul>
</div>
<form>
<input type="hidden" name="evaluation_id" id="formnotes_evaluation_id"
value="{evaluation_id}"/>
</form>
"""
)
H.append(
"""<div class="saisienote_etape2">
<span class="titredivsaisienote">Etape 2 : chargement d'un fichier de notes</span>""" # '
)
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(
"""<p>Le fichier doit être un fichier tableur obtenu via
l'étape 1 ci-dessus, puis complété et enregistré au format Excel.
</p>"""
)
H.append(nf[1])
elif nf[0] == -1:
H.append("<p>Annulation</p>")
elif nf[0] == 1:
updiag = do_evaluation_upload_xls()
if updiag[0]:
H.append(updiag[1])
H.append(
f"""<p>Notes chargées.&nbsp;&nbsp;&nbsp;
<a class="stdlink" href="{
url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
}">
Revenir au tableau de bord du module</a>
&nbsp;&nbsp;&nbsp;
<a class="stdlink" href="{url_for("notes.saisie_notes_tableur",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}">Charger un autre fichier de notes</a>
&nbsp;&nbsp;&nbsp;
<a class="stdlink" href="{url_for("notes.saisie_notes",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}">Formulaire de saisie des notes</a>
</p>"""
)
else:
H.append(
f"""
<p class="redboldtext">Notes non chargées !</p>
{updiag[1]}
<p><a class="stdlink" href="{url_for("notes.saisie_notes_tableur",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}">
Reprendre</a>
</p>
"""
)
#
H.append("""</div><h3>Autres opérations</h3><ul>""")
if evaluation.moduleimpl.can_edit_notes(current_user, allow_ens=False):
H.append(
f"""
<li>
<form action="do_evaluation_set_missing" method="POST">
Mettre toutes les notes manquantes à <input type="text" size="5" name="value"/>
<input type="submit" value="OK"/>
<input type="hidden" name="evaluation_id" value="{evaluation_id}"/>
<em>ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente"</em>
</form>
</li>
<li><a class="stdlink" href="{url_for("notes.evaluation_suppress_alln",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id)
}">Effacer toutes les notes de cette évaluation</a>
(ceci permet ensuite de supprimer l'évaluation si besoin)
</li>
<li><a class="stdlink" href="{url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
}">Revenir au module</a>
</li>
<li><a class="stdlink" href="{url_for("notes.saisie_notes",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}">Revenir au formulaire de saisie</a>
</li>
</ul>
<h3>Explications</h3>
<ol>
<li>Etape 1:
<ol><li>choisir le ou les groupes d'étudiants;</li>
<li>télécharger le fichier Excel à remplir.</li>
</ol>
</li>
<li>Etape 2 (cadre vert): Indiquer le fichier Excel
<em>téléchargé à l'étape 1</em> et dans lequel on a saisi des notes.
Remarques:
<ul>
<li>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;
</li>
<li>seules les valeurs des notes modifiées sont prises en compte;
</li>
<li>seules les notes sont extraites du fichier Excel;
</li>
<li>on peut optionnellement ajouter un commentaire (type "copies corrigées
par Dupont", ou "Modif. suite à contestation") dans la case "Commentaire".
</li>
<li>le fichier Excel <em>doit impérativement être celui chargé à l'étape 1
pour cette évaluation</em>. Il n'est pas possible d'utiliser une liste d'appel
ou autre document Excel téléchargé d'une autre page.
</li>
</ul>
</li>
</ol>
"""
)
H.append(html_sco_header.sco_footer())
return "\n".join(H)