Saisie note excel: améliore feuille et reorganise le code. + affichage date eval sans heures

This commit is contained in:
Emmanuel Viennet 2024-06-28 19:03:20 +02:00
parent 69af0b9778
commit 9a289d5956
7 changed files with 794 additions and 682 deletions

View File

@ -363,6 +363,8 @@ class Evaluation(models.ScoDocModel):
return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}"
if self.date_debut.date() == self.date_fin.date(): # même jour if self.date_debut.date() == self.date_fin.date(): # même jour
if self.date_debut.time() == self.date_fin.time(): if self.date_debut.time() == self.date_fin.time():
if self.date_fin.time() == datetime.time(0, 0):
return f"le {self.date_debut.strftime('%d/%m/%Y')}" # sans heure
return ( return (
f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}"
) )

View File

@ -244,7 +244,10 @@ class ScoExcelSheet:
return style return style
@staticmethod @staticmethod
def i2col(idx): def i2col(idx: int | str) -> str:
"traduit un index ou lettre de colonne en lettre de colonne"
if isinstance(idx, str):
return idx
if idx < 26: # one letter key if idx < 26: # one letter key
return chr(idx + 65) return chr(idx + 65)
else: # two letters AA..ZZ else: # two letters AA..ZZ
@ -252,19 +255,26 @@ class ScoExcelSheet:
second = (idx % 26) + 65 second = (idx % 26) + 65
return "" + chr(first) + chr(second) return "" + chr(first) + chr(second)
def set_column_dimension_width(self, cle=None, value=21): def set_column_dimension_width(self, cle=None, value: int | str | list = 21):
"""Détermine la largeur d'une colonne. cle -- identifie la colonne ("A" "B", ... ou 0, 1, 2, ...) si None, """Détermine la largeur d'une colonne.
value donne la liste des largeurs de colonnes depuis A, B, C, ... value -- la dimension (unité : 7 pixels cle -- identifie la colonne (lettre ou indice à partir de 0),
comme affiché dans Excel) Si cle is None, affecte toutes les colonnes.
value est soit la liste des largeurs de colonnes [ (0, width0), (1, width1), ...]
soit la largeur de la colonne indiquée par cle, soit "auto".
Largeurs en unité : 7 pixels comme affiché dans Excel)
ou value == "auto", ajuste la largeur au contenu
""" """
if cle is None: if cle is None:
for i, val in enumerate(value): cols_widths = enumerate(value)
self.ws.column_dimensions[self.i2col(i)].width = val
# No keys: value is a list of widths
elif isinstance(cle, str): # accepts set_column_with("D", ...)
self.ws.column_dimensions[cle].width = value
else: else:
self.ws.column_dimensions[self.i2col(cle)].width = value cols_widths = [(cle, value)]
for idx, width in cols_widths:
if width == "auto":
self.adjust_column_widths(column_letter=self.i2col(idx))
else:
self.ws.column_dimensions[self.i2col(idx)].width = width
def set_row_dimension_height(self, cle=None, value=21): def set_row_dimension_height(self, cle=None, value=21):
"""Détermine la hauteur d'une ligne. cle -- identifie la ligne (1, 2, ...) si None, """Détermine la hauteur d'une ligne. cle -- identifie la ligne (1, 2, ...) si None,
@ -291,10 +301,48 @@ class ScoExcelSheet:
""" """
self.ws.column_dimensions[cle].hidden = value self.ws.column_dimensions[cle].hidden = value
def set_auto_filter(self, range): def set_auto_filter(self, filter_range):
self.auto_filter = range "met en place un auto-filter excel: le range désigne les cellules de titres"
self.auto_filter = filter_range
def make_cell(self, value: any = None, style=None, comment=None): def adjust_column_widths(
self, column_letter=None, min_row=None, max_row=None, min_col=None, max_col=None
):
"""Adjust columns widths to fit their content.
If column_letter, adjust only this column, else adjust all.
(min_row, max_row, min_col, max_col) can be used to restrinct the area to consider
while determining the widths.
"""
# Create a dictionary to store the maximum width of each column
col_widths = {}
if column_letter is None:
# Iterate over each row and cell in the worksheet
for row in self.ws.iter_rows(
min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col
):
for cell in row:
# Get the length of the cell value (converted to string)
cell_value = str(cell.value)
# Update the maximum width for the column
col_widths[cell.column_letter] = max(
col_widths.get(cell.column_letter, 0), len(cell_value)
)
else:
min_row = self.ws.min_row if min_row is None else min_row
max_row = self.ws.max_row if max_row is None else max_row
for row in range(min_row, max_row + 1):
cell = self.ws[f"{column_letter}{row}"]
cell_value = str(cell.value)
col_widths[cell.column_letter] = max(
col_widths.get(cell.column_letter, 0), len(cell_value)
)
# Set the column widths based on the maximum length found
for col, width in col_widths.items():
self.ws.column_dimensions[col].width = width
def make_cell(self, value: any = None, style: dict = None, comment=None):
"""Construit une cellule. """Construit une cellule.
value -- contenu de la cellule (texte, numérique, booléen ou date) value -- contenu de la cellule (texte, numérique, booléen ou date)
style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié
@ -307,9 +355,8 @@ class ScoExcelSheet:
elif value is False: elif value is False:
value = 0 value = 0
elif isinstance(value, datetime.datetime): elif isinstance(value, datetime.datetime):
value = value.replace( # make date naive (cf https://openpyxl.readthedocs.io/en/latest/datetime.html#timezones)
tzinfo=None value = value.replace(tzinfo=None)
) # make date naive (cf https://openpyxl.readthedocs.io/en/latest/datetime.html#timezones)
# création de la cellule # création de la cellule
cell = WriteOnlyCell(self.ws, value) cell = WriteOnlyCell(self.ws, value)
@ -358,13 +405,14 @@ class ScoExcelSheet:
for value, comment in zip(values, comments) for value, comment in zip(values, comments)
] ]
def append_single_cell_row(self, value: any, style=None): def append_single_cell_row(self, value: any, style=None, prefix=None):
"""construit une ligne composée d'une seule cellule et l'ajoute à la feuille. """construit une ligne composée d'une seule cellule et l'ajoute à la feuille.
mêmes paramètres que make_cell: mêmes paramètres que make_cell:
value -- contenu de la cellule (texte ou numérique) value -- contenu de la cellule (texte ou numérique)
style -- style par défaut de la feuille si non spécifié style -- style par défaut de la feuille si non spécifié
prefix -- cellules ajoutées au début de la ligne
""" """
self.append_row([self.make_cell(value, style)]) self.append_row((prefix or []) + [self.make_cell(value, style)])
def append_blank_row(self): def append_blank_row(self):
"""construit une ligne vide et l'ajoute à la feuille.""" """construit une ligne vide et l'ajoute à la feuille."""
@ -386,14 +434,21 @@ class ScoExcelSheet:
for row in self.rows: for row in self.rows:
self.ws.append(row) self.ws.append(row)
def generate(self): def generate(self, column_widths=None):
"""génération d'un classeur mono-feuille""" """génération d'un classeur mono-feuille"""
# this method makes sense only if it is a standalone worksheet (else call workbook.generate() # this method makes sense for standalone worksheet (else call workbook.generate())
if self.wb is None: # embeded sheet if self.wb is None: # embeded sheet
raise ScoValueError("can't generate a single sheet from a ScoWorkbook") raise ScoValueError("can't generate a single sheet from a ScoWorkbook")
# construction d'un flux (https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream) # construction d'un flux
# https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream
self.prepare() self.prepare()
# largeur des colonnes
if column_widths:
for k, v in column_widths.items():
self.set_column_dimension_width(k, v)
if self.auto_filter is not None: if self.auto_filter is not None:
self.ws.auto_filter.ref = self.auto_filter self.ws.auto_filter.ref = self.auto_filter
with NamedTemporaryFile() as tmp: with NamedTemporaryFile() as tmp:
@ -446,182 +501,6 @@ def excel_simple_table(
return ws.generate() return ws.generate()
def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, lines):
"""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)
# ajuste largeurs colonnes (unite inconnue, empirique)
ws.set_column_dimension_width("A", 11.0 / 7) # codes
# ws.set_column_dimension_hidden("A", True) # codes
ws.set_column_dimension_width("B", 164.00 / 7) # noms
ws.set_column_dimension_width("C", 109.0 / 7) # prenoms
ws.set_column_dimension_width("D", 164.0 / 7) # groupes
ws.set_column_dimension_width("E", 115.0 / 7) # notes
ws.set_column_dimension_width("F", 355.0 / 7) # remarques
ws.set_column_dimension_width("G", 72.0 / 7) # colonne NIP
ws.set_column_dimension_hidden("G", True) # colonne NIP cachée
# fontes
font_base = Font(name="Arial", size=12)
font_bold = Font(name="Arial", bold=True)
font_italic = Font(name="Arial", size=12, italic=True, color=COLORS.RED.value)
font_titre = Font(name="Arial", bold=True, size=14)
font_purple = Font(name="Arial", color=COLORS.PURPLE.value)
font_brown = Font(name="Arial", color=COLORS.BROWN.value)
font_blue = Font(name="Arial", 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
style = {"font": font_base}
style_titres = {"font": font_titre}
style_expl = {"font": font_italic}
style_ro = { # cells read-only
"font": font_purple,
"border": border_right,
}
style_dem = {
"font": font_brown,
"border": border_top,
}
style_nom = { # style pour nom, prenom, groupe
"font": font_base,
"border": border_top,
}
style_notes = {
"font": font_bold,
"number_format": FORMAT_GENERAL,
"fill": fill_light_yellow,
"border": border_top,
}
style_comment = {
"font": font_blue,
"border": border_top,
}
# filtre
filter_top = 8
filter_bottom = 8 + len(lines)
filter_left = "A"
filter_right = "G"
ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")
# ligne de titres
ws.append_single_cell_row(
"Feuille saisie note (à enregistrer au format excel)", style_titres
)
# lignes d'instructions
ws.append_single_cell_row(
"Saisir les notes dans la colonne E (cases jaunes)", style_expl
)
ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl)
# Nom du semestre
ws.append_single_cell_row(scu.unescape_html(titreannee), style_titres)
# description evaluation
ws.append_single_cell_row(scu.unescape_html(description), style_titres)
ws.append_single_cell_row(
f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})",
style,
)
# ligne blanche
ws.append_blank_row()
# code et titres colonnes
ws.append_row(
[
ws.make_cell("!%s" % evaluation.id, style_ro),
ws.make_cell("Nom", style_titres),
ws.make_cell("Prénom", style_titres),
ws.make_cell("Groupe", style_titres),
ws.make_cell("Note sur %g" % (evaluation.note_max or 0.0), style_titres),
ws.make_cell("Remarque", style_titres),
ws.make_cell("NIP", style_titres),
]
)
# etudiants
for line in lines:
st = style_nom
if line[3] != "I":
st = style_dem
if line[3] == "D": # demissionnaire
s = "DEM"
else:
s = line[3] # etat autre
else:
s = line[4] # groupes TD/TP/...
try:
val = float(line[5])
except ValueError:
val = line[5]
ws.append_row(
[
ws.make_cell("!" + line[0], style_ro), # code
ws.make_cell(line[1], st),
ws.make_cell(line[2], st),
ws.make_cell(s, st),
ws.make_cell(val, style_notes), # note
ws.make_cell(line[6], style_comment), # comment
ws.make_cell(line[7], style_ro), # NIP
]
)
# ligne blanche
ws.append_blank_row()
# explication en bas
ws.append_row([None, ws.make_cell("Code notes", style_titres)])
ws.append_row(
[
None,
ws.make_cell("ABS", style_expl),
ws.make_cell("absent (0)", style_expl),
]
)
ws.append_row(
[
None,
ws.make_cell("EXC", style_expl),
ws.make_cell("pas prise en compte", style_expl),
]
)
ws.append_row(
[
None,
ws.make_cell("ATT", style_expl),
ws.make_cell("en attente", style_expl),
]
)
ws.append_row(
[
None,
ws.make_cell("SUPR", style_expl),
ws.make_cell("pour supprimer note déjà entrée", style_expl),
]
)
ws.append_row(
[
None,
ws.make_cell("", style_expl),
ws.make_cell("cellule vide -> note non modifiée", style_expl),
]
)
return ws.generate()
def excel_bytes_to_list(bytes_content): def excel_bytes_to_list(bytes_content):
try: try:
filelike = io.BytesIO(bytes_content) filelike = io.BytesIO(bytes_content)

View File

@ -0,0 +1,655 @@
##############################################################################
#
# 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)

View File

@ -1,6 +1,3 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
############################################################################## ##############################################################################
# #
# Gestion scolarite IUT # Gestion scolarite IUT
@ -34,7 +31,7 @@ import time
import flask import flask
from flask import g, url_for, request from flask import g, url_for
from flask_login import current_user from flask_login import current_user
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
import psycopg2 import psycopg2
@ -55,25 +52,23 @@ from app.models.etudiants import Identite
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
InvalidNoteValue,
NoteProcessError, NoteProcessError,
ScoException, ScoException,
ScoInvalidParamError, ScoInvalidParamError,
ScoValueError, ScoValueError,
) )
from app.scodoc import html_sco_header, sco_users from app.scodoc import html_sco_header
from app.scodoc import htmlutils from app.scodoc import htmlutils
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db
from app.scodoc import sco_evaluations from app.scodoc import sco_evaluations
from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc import sco_groups_view from app.scodoc import sco_groups_view
from app.scodoc import sco_undo_notes from app.scodoc import sco_undo_notes
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app.scodoc.TrivialFormulator import TrivialFormulator, TF from app.scodoc.TrivialFormulator import TF
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -116,25 +111,7 @@ def convert_note_from_string(
return note_value, invalid return note_value, invalid
def _display_note(val): def check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
"""Convert note from DB to viewable string.
Utilisé seulement pour I/O vers formulaires (sans perte de precision)
(Utiliser fmt_note pour les affichages)
"""
if val is None:
val = "ABS"
elif val == scu.NOTES_NEUTRALISE:
val = "EXC" # excuse, note neutralise
elif val == scu.NOTES_ATTENTE:
val = "ATT" # attente, note neutralise
elif val == scu.NOTES_SUPPRESS:
val = "SUPR"
else:
val = "%g" % val
return val
def _check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
"""notes is a list of tuples (etudid, value) """notes is a list of tuples (etudid, value)
mod is the module (used to ckeck type, for malus) mod is the module (used to ckeck type, for malus)
returns list of valid notes (etudid, float value) returns list of valid notes (etudid, float value)
@ -193,137 +170,6 @@ def _check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
) )
def do_evaluation_upload_xls():
"""
Soumission d'un fichier XLS (evaluation_id, notefile)
"""
vals = scu.get_request_args()
evaluation_id = int(vals["evaluation_id"])
comment = vals["comment"]
evaluation: Evaluation = Evaluation.query.get_or_404(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(vals["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, _ = _check_notes(
notes, evaluation
)
if invalids:
diag.append(
f"Erreur: la feuille contient {len(invalids)} notes invalides</p>"
)
if len(invalids) < 25:
etudsnames = [
sco_etud.get_etud_info(etudid=etudid, filled=True)[0]["nomprenom"]
for etudid in invalids
]
diag.append("Notes invalides pour: " + ", ".join(etudsnames))
raise InvalidNoteValue()
else:
etudids_changed, nb_suppress, etudids_with_decisions, messages = 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 do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -> bool: def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -> bool:
"""Enregistre la note d'un seul étudiant """Enregistre la note d'un seul étudiant
value: valeur externe (float ou str) value: valeur externe (float ou str)
@ -331,10 +177,10 @@ def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -
if not evaluation.moduleimpl.can_edit_notes(current_user): if not evaluation.moduleimpl.can_edit_notes(current_user):
raise AccessDenied(f"Modification des notes impossible pour {current_user}") raise AccessDenied(f"Modification des notes impossible pour {current_user}")
# Convert and check value # Convert and check value
L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation) notes, invalids, _, _, _ = check_notes([(etud.id, value)], evaluation)
if len(invalids) == 0: if len(invalids) == 0:
etudids_changed, _, _, _ = notes_add( etudids_changed, _, _, _ = notes_add(
current_user, evaluation.id, L, "Initialisation notes" current_user, evaluation.id, notes, "Initialisation notes"
) )
if len(etudids_changed) == 1: if len(etudids_changed) == 1:
return True return True
@ -371,7 +217,7 @@ def do_evaluation_set_missing(
if etudid not in notes_db: # pas de note if etudid not in notes_db: # pas de note
notes.append((etudid, value)) notes.append((etudid, value))
# Convert and check values # Convert and check values
valid_notes, invalids, _, _, _ = _check_notes(notes, evaluation) valid_notes, invalids, _, _, _ = check_notes(notes, evaluation)
dest_url = url_for( dest_url = url_for(
"notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id
) )
@ -615,7 +461,7 @@ def notes_add(
etudids_changed.append(etudid) etudids_changed.append(etudid)
if res.etud_has_decision(etudid, include_rcues=False): if res.etud_has_decision(etudid, include_rcues=False):
etudids_with_decision.append(etudid) etudids_with_decision.append(etudid)
except Exception as exc: except NotImplementedError as exc: # XXX
log("*** exception in notes_add") log("*** exception in notes_add")
if do_it: if do_it:
cnx.rollback() # abort cnx.rollback() # abort
@ -675,12 +521,13 @@ def _record_note(
else: else:
# il y a deja une note # il y a deja une note
oldval = notes_db[etudid]["value"] oldval = notes_db[etudid]["value"]
if type(value) != type(oldval): changed = (
changed = True (not isinstance(value, type(oldval)))
elif isinstance(value, float) and (abs(value - oldval) > scu.NOTES_PRECISION): or (
changed = True isinstance(value, float) and (abs(value - oldval) > scu.NOTES_PRECISION)
elif value != oldval: )
changed = True or value != oldval
)
if changed: if changed:
# recopie l'ancienne note dans notes_notes_log, puis update # recopie l'ancienne note dans notes_notes_log, puis update
if do_it: if do_it:
@ -731,284 +578,7 @@ def _record_note(
return changed, suppressed return changed, suppressed
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)
def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None):
"""Document Excel pour saisie notes dans l'évaluation et les groupes indiqués"""
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if not evaluation:
raise ScoValueError("invalid 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 = _get_sorted_etuds(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(
[
str(etudid),
e.get("nom_disp", "") or e.get("nom_usuel", "") or e["nom"],
e["prenom"].lower().capitalize(),
e["inscr"]["etat"],
grc,
e["val"],
e["explanation"],
e["code_nip"],
]
)
filename = f"notes_{eval_name}_{gr_title_filename}"
xls = sco_excel.excel_feuille_saisie(
evaluation, formsemestre.titre_annee(), description, lines=rows
)
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
# -----------------------------
# Nouveau formulaire saisie notes (2016) # Nouveau formulaire saisie notes (2016)
def saisie_notes(evaluation_id: int, group_ids: list = None): def saisie_notes(evaluation_id: int, group_ids: list = None):
"""Formulaire saisie notes d'une évaluation pour un groupe""" """Formulaire saisie notes d'une évaluation pour un groupe"""
if not isinstance(evaluation_id, int): if not isinstance(evaluation_id, int):
@ -1095,9 +665,11 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
) )
H.append( H.append(
""" """
</td> </td>
<td style="padding-left: 35px;"><button class="btn_masquer_DEM">Masquer les DEM</button></td> <td style="padding-left: 35px;">
</tr> <button class="btn_masquer_DEM">Masquer les DEM</button>
</td>
</tr>
</table> </table>
</div> </div>
<style> <style>
@ -1144,8 +716,10 @@ def saisie_notes(evaluation_id: int, group_ids: list = None):
return "\n".join(H) return "\n".join(H)
def _get_sorted_etuds(evaluation: Evaluation, etudids: list, formsemestre_id: int): def get_sorted_etuds_notes(
# Notes existantes evaluation: Evaluation, etudids: list, formsemestre_id: int
) -> list[dict]:
"""Liste d'infos sur les notes existantes pour les étudiants indiqués"""
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id) notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
etuds = [] etuds = []
@ -1182,7 +756,9 @@ def _get_sorted_etuds(evaluation: Evaluation, etudids: list, formsemestre_id: in
# Note actuelle de l'étudiant: # Note actuelle de l'étudiant:
if etudid in notes_db: if etudid in notes_db:
e["val"] = _display_note(notes_db[etudid]["value"]) e["val"] = scu.fmt_note(
notes_db[etudid]["value"], fixed_precision_str=False
)
comment = notes_db[etudid]["comment"] comment = notes_db[etudid]["comment"]
if comment is None: if comment is None:
comment = "" comment = ""
@ -1236,7 +812,7 @@ def _form_saisie_notes(
# Nb de décisions de jury (pour les inscrits à l'évaluation): # Nb de décisions de jury (pour les inscrits à l'évaluation):
nb_decisions = sum(decisions_jury.values()) nb_decisions = sum(decisions_jury.values())
etuds = _get_sorted_etuds(evaluation, etudids, formsemestre_id) etuds = get_sorted_etuds_notes(evaluation, etudids, formsemestre_id)
# Build form: # Build form:
descr = [ descr = [
@ -1263,7 +839,7 @@ def _form_saisie_notes(
"title": "Notes ", "title": "Notes ",
"cssclass": "formnote_bareme", "cssclass": "formnote_bareme",
"readonly": True, "readonly": True,
"default": "&nbsp;/ %g" % evaluation.note_max, "default": f"&nbsp;/ {evaluation.note_max:g}",
}, },
) )
) )
@ -1299,13 +875,8 @@ def _form_saisie_notes(
for group_info in e["groups"]: for group_info in e["groups"]:
etud_classes.append("group-" + str(group_info["group_id"])) etud_classes.append("group-" + str(group_info["group_id"]))
label = ( label = f"""<span class="{classdem}">{e["civilite_str"]} {
'<span class="%s">' % classdem scu.format_nomprenom(e, reverse=True)}</span>"""
+ e["civilite_str"]
+ " "
+ scu.format_nomprenom(e, reverse=True)
+ "</span>"
)
# Historique des saisies de notes: # Historique des saisies de notes:
explanation = ( explanation = (
@ -1322,7 +893,7 @@ def _form_saisie_notes(
# Valeur actuelle du champ: # Valeur actuelle du champ:
initvalues["note_" + str(etudid)] = e["val"] initvalues["note_" + str(etudid)] = e["val"]
label_link = '<a class="etudinfo" id="%s">%s</a>' % (etudid, label) label_link = f'<a class="etudinfo" id="{etudid}">{label}</a>'
# Element de formulaire: # Element de formulaire:
descr.append( descr.append(
@ -1334,11 +905,11 @@ def _form_saisie_notes(
"explanation": explanation, "explanation": explanation,
"return_focus_next": True, "return_focus_next": True,
"attributes": [ "attributes": [
'class="note%s"' % classdem, f'class="note{classdem}"',
disabled_attr, disabled_attr,
'data-last-saved-value="%s"' % e["val"], f'''data-last-saved-value="{e['val']}"''',
'data-orig-value="%s"' % e["val"], f'''data-orig-value="{e["val"]}"''',
'data-etudid="%s"' % etudid, f'data-etudid="{etudid}"',
], ],
"template": """<tr%(item_dom_attr)s class="etud_elem """ "template": """<tr%(item_dom_attr)s class="etud_elem """
+ " ".join(etud_classes) + " ".join(etud_classes)
@ -1422,7 +993,7 @@ def save_notes(
if not evaluation.moduleimpl.can_edit_notes(current_user): if not evaluation.moduleimpl.can_edit_notes(current_user):
return json_error(403, "modification notes non autorisee pour cet utilisateur") return json_error(403, "modification notes non autorisee pour cet utilisateur")
# #
valid_notes, _, _, _, _ = _check_notes(notes, evaluation) valid_notes, _, _, _, _ = check_notes(notes, evaluation)
if valid_notes: if valid_notes:
etudids_changed, _, etudids_with_decision, messages = notes_add( etudids_changed, _, etudids_with_decision, messages = notes_add(
current_user, evaluation.id, valid_notes, comment=comment, do_it=True current_user, evaluation.id, valid_notes, comment=comment, do_it=True
@ -1464,8 +1035,7 @@ def get_note_history_menu(evaluation_id: int, etudid: int) -> str:
H = [] H = []
if len(history) > 1: if len(history) > 1:
H.append( H.append(
'<select data-etudid="%s" class="note_history" onchange="change_history(this);">' f'<select data-etudid="{etudid}" class="note_history" onchange="change_history(this);">'
% etudid
) )
envir = "select" envir = "select"
item = "option" item = "option"
@ -1477,21 +1047,19 @@ def get_note_history_menu(evaluation_id: int, etudid: int) -> str:
first = True first = True
for i in history: for i in history:
jt = i["date"].strftime("le %d/%m/%Y à %H:%M") + " (%s)" % i["user_name"] jt = f"""{i["date"].strftime("le %d/%m/%Y à %H:%M")} ({i["user_name"]})"""
dispnote = _display_note(i["value"]) dispnote = scu.fmt_note(i["value"], fixed_precision_str=False)
if first: if first:
nv = "" # ne repete pas la valeur de la note courante nv = "" # ne repete pas la valeur de la note courante
else: else:
# ancienne valeur # ancienne valeur
nv = ": %s" % dispnote nv = f": {dispnote}"
first = False first = False
if i["comment"]: if i["comment"]:
comment = ' <span class="histcomment">%s</span>' % i["comment"] comment = f' <span class="histcomment">{i["comment"]}</span>'
else: else:
comment = "" comment = ""
H.append( H.append(f'<{item} data-note="{dispnote}">{jt} {nv}{comment}</{item}>')
'<%s data-note="%s">%s %s%s</%s>' % (item, dispnote, jt, nv, comment, item)
)
H.append("</%s>" % envir) H.append(f"</{envir}>")
return "\n".join(H) return "\n".join(H)

View File

@ -560,9 +560,13 @@ DATEATIME_FMT = DATE_FMT + " à " + TIME_FMT
DATETIME_FMT = DATE_FMT + " " + TIME_FMT DATETIME_FMT = DATE_FMT + " " + TIME_FMT
def fmt_note(val, note_max=None, keep_numeric=False): def fmt_note(
val, note_max=None, keep_numeric=False, fixed_precision_str=True
) -> str | float:
"""conversion note en str pour affichage dans tables HTML ou PDF. """conversion note en str pour affichage dans tables HTML ou PDF.
Si keep_numeric, laisse les valeur numeriques telles quelles (pour export Excel) Si keep_numeric, laisse les valeur numeriques telles quelles (pour export Excel)
Si fixed_precision_str (défaut), formatte sur 4 chiffres ("01.23"),
sinon utilise %g (chaine précision variable, pour les formulaires)
""" """
if val is None or val == NOTES_ABSENCE: if val is None or val == NOTES_ABSENCE:
return "ABS" return "ABS"
@ -570,6 +574,8 @@ def fmt_note(val, note_max=None, keep_numeric=False):
return "EXC" # excuse, note neutralise return "EXC" # excuse, note neutralise
if val == NOTES_ATTENTE: if val == NOTES_ATTENTE:
return "ATT" # attente, note neutralisee return "ATT" # attente, note neutralisee
if val == NOTES_SUPPRESS:
return "SUPR" # pour les formulaires de saisie
if not isinstance(val, str): if not isinstance(val, str):
if np.isnan(val): if np.isnan(val):
return "~" return "~"
@ -577,12 +583,13 @@ def fmt_note(val, note_max=None, keep_numeric=False):
val = val * 20.0 / note_max val = val * 20.0 / note_max
if keep_numeric: if keep_numeric:
return val return val
else: if fixed_precision_str:
s = "%2.2f" % round(float(val), 2) # 2 chiffres apres la virgule s = f"{round(float(val), 2):2.2f}" # 2 chiffres apres la virgule
s = "0" * (5 - len(s)) + s # padding: 0 à gauche pour longueur 5: "12.34" s = "0" * (5 - len(s)) + s # padding: 0 à gauche pour longueur 5: "12.34"
return s return s
else: return f"{val:g}"
return val.replace("NA", "-")
return val.replace("NA", "-")
def fmt_coef(val): def fmt_coef(val):

View File

@ -147,6 +147,7 @@ from app.scodoc import (
sco_recapcomplet, sco_recapcomplet,
sco_report, sco_report,
sco_report_but, sco_report_but,
sco_saisie_excel,
sco_saisie_notes, sco_saisie_notes,
sco_semset, sco_semset,
sco_synchro_etuds, sco_synchro_etuds,
@ -1827,13 +1828,13 @@ sco_publish(
# --- Saisie des notes # --- Saisie des notes
sco_publish( sco_publish(
"/saisie_notes_tableur", "/saisie_notes_tableur",
sco_saisie_notes.saisie_notes_tableur, sco_saisie_excel.saisie_notes_tableur,
Permission.EnsView, Permission.EnsView,
methods=["GET", "POST"], methods=["GET", "POST"],
) )
sco_publish( sco_publish(
"/feuille_saisie_notes", "/feuille_saisie_notes",
sco_saisie_notes.feuille_saisie_notes, sco_saisie_excel.feuille_saisie_notes,
Permission.EnsView, Permission.EnsView,
) )
sco_publish("/saisie_notes", sco_saisie_notes.saisie_notes, Permission.EnsView) sco_publish("/saisie_notes", sco_saisie_notes.saisie_notes, Permission.EnsView)

View File

@ -1,7 +1,7 @@
# -*- mode: python -*- # -*- mode: python -*-
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
SCOVERSION = "9.6.984" SCOVERSION = "9.6.985"
SCONAME = "ScoDoc" SCONAME = "ScoDoc"