ScoDoc/app/scodoc/sco_saisie_excel.py

1204 lines
42 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
## 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)
-> formsemestre_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, ModuleImpl, ScolarNews
from app.scodoc.sco_excel import COLORS, ScoExcelSheet
from app.scodoc import (
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, ScoValueError
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 | None = None, modimpl: ModuleImpl | None = None
) -> AnyStr:
"""Génère feuille pour import toutes notes dans ce semestre ou ce module,
avec une colonne par évaluation.
Return excel data
"""
if not (formsemestre or modimpl):
raise ScoValueError("excel_feuille_import: missing argument")
evaluations = (
formsemestre.get_evaluations() if formsemestre else modimpl.evaluations.all()
)
if formsemestre is None:
if not evaluations:
raise ScoValueError(
"pas d'évaluations dans ce module",
dest_url=url_for(
"notes.moduleimpl_status, scodoc_dept=g.scodoc_dept, moduleimpl_id=modipl.id"
),
)
formsemestre = evaluations[0].moduleimpl.formsemestre
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,
modimpl: ModuleImpl | None = None,
) -> tuple[bool, str]:
"""
Soumission d'un fichier XLS (evaluation_id, notefile)
soit dans le formsemestre (import multi-eval)
soit dans toules les évaluations du modimpl
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,
modimpl=modimpl,
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, modimpl_ids_changed = (
_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
if evaluation.moduleimpl_id in modimpl_ids_changed
}
)
status_url = (
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)
if formsemestre
else (
url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
if modimpl
else ""
)
)
obj_id = (
formsemestre.id if formsemestre else (modimpl.id if modimpl else None)
)
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"""Notes dans <a href="{status_url}">{modules_str}</a>""",
url=status_url,
max_frequency=10 * 60, # 10 minutes
)
msg = "<div>" + "\n".join(messages_by_eval.values()) + "</div>"
if etudids_with_decisions:
msg = (
"""<p class="warning"><b>Important:</b>
Il y avait déjà des décisions de jury
enregistrées, qui sont à revoir suite à cette modification !
</p>
"""
+ msg
)
return True, 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 False, msg + "<p>(pas de notes modifiées)</p>"
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], set[int]]:
"""Enregistre les notes dans les évaluations
Return:
messages_by_eval : dict { evaluation_id : message }
etudids_with_decisions : set of etudids with decision and mark changed
modimpl_ids_changed : set of ModuleImplId where at least one mark changed
"""
# -- 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()
modimpl_ids_changed = 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)
if etudids_changed:
modimpl_ids_changed.add(evaluation.moduleimpl_id)
msg = f"""<div class="diag-evaluation">
<ul>
<li><div class="{'diag-change' if etudids_changed else 'diag-nochange'}">
Module {evaluation.moduleimpl.module.code} :
évaluation {evaluation.description} {evaluation.descr_date()}
"""
msg += (
f"""
</div>
<div>
{len(etudids_changed)} notes changées
({len(etudids_without_notes_by_eval[evaluation.id])} sans notes,
{len(etudids_absents_by_eval[evaluation.id])} absents,
{nb_suppress} note supprimées)
</div>
"""
if etudids_changed
else " : pas de changement</div>"
)
msg += "</li></ul>"
if messages:
msg += f"""<div class="warning">Attention&nbsp;:
<ul>
<li>{
'</li><li>'.join(messages)
}
</li>
</ul>
</div>"""
msg += """</div>"""
messages_by_eval[evaluation.id] = msg
return messages_by_eval, etudids_with_decisions, modimpl_ids_changed
def _check_notes_evaluations(
evaluations: list[Evaluation],
notes_by_eval: dict[int, list[tuple[int, str]]],
diag: list[str],
rows: list[list[str]] | None = None,
) -> 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")
msg = f"""Notes invalides dans {
evaluation.moduleimpl.module.code} {evaluation.description} pour : """
if len(invalids) < 25:
etudsnames = [
Identite.get_etud(etudid).nom_prenom() for etudid in invalids
]
msg += ", ".join(etudsnames)
else:
msg += "plus de 25 étudiants"
diag.append(msg)
raise InvalidNoteValue()
if etudids_non_inscrits:
msg = ""
if len(etudids_non_inscrits) < 25:
# retrouve numéro ligne et données invalides dans fichier
for etudid in etudids_non_inscrits:
try:
index = [row[0] for row in rows].index(f"!{etudid}")
except ValueError:
index = None
msg += f"""<li>Ligne {index+1}:
{rows[index][1]} {rows[index][2]} (id={rows[index][0]})
</li>"""
else:
msg += "<li>sur plus de 25 lignes</li>"
diag.append(
f"""Erreur: la feuille contient {len(etudids_non_inscrits)
} étudiants inexistants ou non inscrits à l'évaluation
{evaluation.moduleimpl.module.code}
{evaluation.description}
<ul>{msg}</ul>
"""
)
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 _xls_search_sheet_code(
rows: list[list[str]], diag: list[str] = None
) -> tuple[int, int | dict[int, int]]:
"""Cherche dans la feuille (liste de listes de chaines)
la ligne identifiant la ou les évaluations.
Si MULTIEVAL (import de plusieurs), renvoie
- l'indice de la ligne des TITRES
- un dict evaluations_col_idx: { evaluation_id : indice de sa colonne dans la feuille }
Si une seule éval (chargement dans une évaluation)
- l'indice de la ligne des TITRES
- l'evaluation_id indiqué dans la feuille
"""
for i, row in enumerate(rows):
if not row:
diag.append("Erreur: feuille invalide (ligne vide ?)")
raise InvalidNoteValue()
eval_code = row[0].strip()
# -- eval code: first cell in 1st column beginning by "!"
if eval_code.startswith("!"): # code évaluation trouvé
try:
sheet_eval_id = int(eval_code[1:])
except ValueError as exc:
diag.append("Erreur: feuille invalide ! (code évaluation invalide)")
raise InvalidNoteValue() from exc
return i, sheet_eval_id
# -- Muti-évaluation: les codes sont au dessus des titres
elif eval_code == "MULTIEVAL": # feuille import multi-eval
# cherche les ids des évaluations sur la même ligne
try:
evaluation_ids = [int(x) for x in row[4:]]
except ValueError as exc:
diag.append(
f"Erreur: feuille invalide ! (code évaluation invalide sur ligne {i+1})"
)
raise InvalidNoteValue() from exc
evaluations_col_idx = {
evaluation_id: j
for (j, evaluation_id) in enumerate(evaluation_ids, start=4)
}
return (
i + 1, # i+1 car MULTIEVAL sur la ligne précédent les titres
evaluations_col_idx,
)
diag.append("Erreur: feuille invalide ! (pas de ligne code évaluation)")
raise InvalidNoteValue()
def _get_sheet_evaluations(
rows: list[list[str]],
evaluation: Evaluation | None = None,
formsemestre: FormSemestre | None = None,
modimpl: ModuleImpl | 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
modimpl (optionel): le moduleimpl dans lequel sont les évaluations à remplir
formsemestre ou evaluation ou modimpl 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 }
"""
i, r = _xls_search_sheet_code(rows, diag)
if isinstance(r, int): # mono-eval
sheet_eval_id = r
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}
if isinstance(r, dict): # multi-eval
evaluations = []
evaluations_col_idx = r
# Load and check evaluations
for evaluation_id in evaluations_col_idx:
evaluation = Evaluation.get_evaluation(evaluation_id, accept_none=True)
if evaluation is None:
diag.append(f"""Erreur: l'évaluation {evaluation_id} n'existe pas""")
raise InvalidNoteValue()
if (
formsemestre
and evaluation.moduleimpl.formsemestre_id != formsemestre.id
):
diag.append(
f"""Erreur: l'évaluation {evaluation_id} n'existe pas dans ce semestre"""
)
raise InvalidNoteValue()
if modimpl and evaluation.moduleimpl.id != modimpl.id:
diag.append(
f"""Erreur: l'évaluation {evaluation_id} n'existe pas dans ce semestre"""
)
raise InvalidNoteValue()
evaluations.append(evaluation)
return i, evaluations, evaluations_col_idx
raise ValueError("_get_sheet_evaluations")
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):
dest_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=moduleimpl_id,
)
raise ScoValueError(
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="{dest_url}">Continuer</a></p>
""",
safe=True,
dest_url="",
)
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 = [
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">Étape 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">Étape 2 : chargement d'un fichier de notes</span>""" # '
)
tf = 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 tf[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(tf[1])
elif tf[0] == -1:
H.append("<p>Annulation</p>")
elif tf[0] == 1:
args = scu.get_request_args()
evaluation = Evaluation.get_evaluation(args["evaluation_id"])
ok, diagnostic_msg = do_evaluations_upload_xls(
notefile=args["notefile"], evaluation=evaluation, comment=args["comment"]
)
if ok:
H.append(
f"""
<div class="notes-chargees">
<div><b>Notes chargées !</b></div>
{diagnostic_msg}
</div>
<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>
</div>"""
)
else:
H.append(
f"""
<p class="redboldtext">Notes non chargées !</p>
{diagnostic_msg}
<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>""")
if evaluation.moduleimpl.can_edit_notes(current_user, allow_ens=False):
H.append(
f"""
<div class="scobox explanation">
<div class="scobox-title">Autres opérations</div>
<div>
<ul>
<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>
</div>
</div>
<div class="scobox explanation">
<div class="scobox-title">Explications</div>
<div>
<ol>
<li><b>Etape 1</b>:
<ol><li>choisir le ou les groupes d'étudiants;</li>
<li>télécharger le fichier Excel à remplir.</li>
</ol>
</li>
<li><b>Etape 2</b> (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 plus tard (en téléchargeant un nouveau fichier ou en
passant par le formulaire de saisie);
</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>
</div>
</div>
"""
)
return render_template(
"sco_page.j2",
content="\n".join(H),
page_title=page_title,
javascripts=sco_groups_view.JAVASCRIPTS,
cssstyles=sco_groups_view.CSSSTYLES,
)
def formsemestre_import_notes(
formsemestre: FormSemestre | None = None,
modimpl: ModuleImpl | None = None,
notefile="",
comment: str = "",
):
"""Importation de notes dans plusieurs évaluations
du formsemestre ou du modimpl"""
ok, diagnostic_msg = do_evaluations_upload_xls(
notefile=notefile, formsemestre=formsemestre, modimpl=modimpl, comment=comment
)
return render_template(
"formsemestre/import_notes_after.j2",
comment=comment,
ok=ok,
diagnostic_msg=diagnostic_msg,
modimpl=modimpl,
sco=ScoData(formsemestre=formsemestre or modimpl.formsemestre),
title="Importation des notes",
)