forked from ScoDoc/ScoDoc
1140 lines
40 KiB
Python
1140 lines
40 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)
|
|
-> 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
|
|
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 <a href="{status_url}">{modules_str}</a>""",
|
|
url=status_url,
|
|
max_frequency=30 * 60, # 30 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]]:
|
|
"""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"""<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 :
|
|
<ul>
|
|
<li>{
|
|
'</li><li>'.join(messages)
|
|
}
|
|
</li>
|
|
</ul>
|
|
</div>"""
|
|
msg += """</div>"""
|
|
messages_by_eval[evaluation.id] = msg
|
|
return messages_by_eval, etudids_with_decisions
|
|
|
|
|
|
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,
|
|
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 }
|
|
"""
|
|
|
|
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 evaluation.moduleimpl.formsemestre_id != formsemestre.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):
|
|
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">É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(
|
|
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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
"""
|
|
)
|
|
H.append(html_sco_header.sco_footer())
|
|
return "\n".join(H)
|
|
|
|
|
|
def formsemestre_import_notes(formsemestre: FormSemestre, notefile, comment: str):
|
|
"""Importation de notes dans plusieurs évaluations du semestre"""
|
|
ok, diagnostic_msg = do_evaluations_upload_xls(
|
|
notefile, formsemestre=formsemestre, comment=comment
|
|
)
|
|
return render_template(
|
|
"formsemestre/import_notes_after.j2",
|
|
comment=comment,
|
|
ok=ok,
|
|
diagnostic_msg=diagnostic_msg,
|
|
sco=ScoData(formsemestre=formsemestre),
|
|
title="Importation des notes",
|
|
)
|