Chargement notes excel: réorganisation du code

This commit is contained in:
Emmanuel Viennet 2024-06-30 23:00:42 +02:00
parent 8f12c452df
commit c4b44a1022
9 changed files with 466 additions and 263 deletions

View File

@ -197,18 +197,28 @@ class Identite(models.ScoDocModel):
return cls.query.filter_by(**args).first_or_404() return cls.query.filter_by(**args).first_or_404()
@classmethod @classmethod
def get_etud(cls, etudid: int) -> "Identite": def get_etud(cls, etudid: int, accept_none=False) -> "Identite":
"""Etudiant ou 404, cherche uniquement dans le département courant""" """Etudiant ou 404 (ou None si accept_none),
cherche uniquement dans le département courant.
Si accept_none, return None si l'id est invalide ou ne correspond
pas à un étudiant.
"""
if not isinstance(etudid, int): if not isinstance(etudid, int):
try: try:
etudid = int(etudid) etudid = int(etudid)
except (TypeError, ValueError): except (TypeError, ValueError):
if accept_none:
return None
abort(404, "etudid invalide") abort(404, "etudid invalide")
if g.scodoc_dept:
return cls.query.filter_by( query = (
id=etudid, dept_id=g.scodoc_dept_id cls.query.filter_by(id=etudid, dept_id=g.scodoc_dept_id)
).first_or_404() if g.scodoc_dept
return cls.query.filter_by(id=etudid).first_or_404() else cls.query.filter_by(id=etudid)
)
if accept_none:
return query.first()
return query.first_or_404()
@classmethod @classmethod
def create_etud(cls, **args) -> "Identite": def create_etud(cls, **args) -> "Identite":

View File

@ -274,21 +274,39 @@ class ModuleImpl(ScoDocModel):
return False return False
return True return True
def est_inscrit(self, etud: Identite) -> bool: def est_inscrit(self, etud: Identite):
""" """
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre). Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).
(lent, pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df). (lent, pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
Retourne Vrai si inscrit au module, faux sinon. Retourne ModuleImplInscription si inscrit au module, False sinon.
""" """
# vérifie inscrit au moduleimpl ET au formsemestre
from app.models.formsemestre import FormSemestre, FormSemestreInscription
is_module: int = ( inscription = (
ModuleImplInscription.query.filter_by( ModuleImplInscription.query.filter_by(etudid=etud.id, moduleimpl_id=self.id)
etudid=etud.id, moduleimpl_id=self.id .join(ModuleImpl)
).count() .join(FormSemestre)
> 0 .join(FormSemestreInscription)
.filter_by(etudid=etud.id)
.first()
) )
return is_module return inscription or False
def query_inscriptions(self) -> Query:
"""Query ModuleImplInscription: inscrits au moduleimpl et au formsemestre
(pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df).
"""
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
ModuleImplInscription.query.filter_by(moduleimpl_id=self.id)
.join(ModuleImpl)
.join(FormSemestre)
.join(FormSemestreInscription)
.filter_by(etudid=ModuleImplInscription.etudid)
)
# Enseignants (chargés de TD ou TP) d'un moduleimpl # Enseignants (chargés de TD ou TP) d'un moduleimpl

View File

@ -1472,7 +1472,7 @@ def do_evaluation_listeetuds_groups(
include_demdef: bool = False, include_demdef: bool = False,
) -> list[tuple[int, str]]: ) -> list[tuple[int, str]]:
"""Donne la liste non triée des etudids inscrits à cette évaluation dans les """Donne la liste non triée des etudids inscrits à cette évaluation dans les
groupes indiqués. groupes indiqués (donc inscrits au modimpl ET au formsemestre).
Si getallstudents==True, donne tous les étudiants inscrits à cette Si getallstudents==True, donne tous les étudiants inscrits à cette
evaluation. evaluation.
Si include_demdef, compte aussi les etudiants démissionnaires et défaillants Si include_demdef, compte aussi les etudiants démissionnaires et défaillants

View File

@ -24,11 +24,11 @@
"""Fichier excel de saisie des notes """Fichier excel de saisie des notes
""" """
from collections import defaultdict
from openpyxl.styles import Font, Border, Side, Alignment, PatternFill from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from openpyxl.styles.numbers import FORMAT_GENERAL from openpyxl.styles.numbers import FORMAT_GENERAL
from flask import g, request, url_for from flask import flash, g, request, url_for
from flask_login import current_user from flask_login import current_user
from app.models import Evaluation, Identite, Module, ScolarNews from app.models import Evaluation, Identite, Module, ScolarNews
@ -49,9 +49,7 @@ from app.scodoc.TrivialFormulator import TrivialFormulator
FONT_NAME = "Arial" FONT_NAME = "Arial"
def excel_feuille_saisie( def excel_feuille_saisie(evaluation: "Evaluation", rows: list[dict]):
evaluation: "Evaluation", titreannee, description, rows: list[dict]
):
"""Genere feuille excel pour saisie des notes. """Genere feuille excel pour saisie des notes.
E: evaluation (dict) E: evaluation (dict)
lines: liste de tuples lines: liste de tuples
@ -59,106 +57,15 @@ def excel_feuille_saisie(
""" """
sheet_name = "Saisie notes" sheet_name = "Saisie notes"
ws = ScoExcelSheet(sheet_name) ws = ScoExcelSheet(sheet_name)
styles = _build_styles()
nb_lines_titles = _insert_top_title(ws, styles, evaluation=evaluation)
# fontes _insert_line_titles(
font_base = Font(name=FONT_NAME, size=12) ws,
font_bold = Font(name=FONT_NAME, bold=True) nb_lines_titles,
font_italic = Font(name=FONT_NAME, size=12, italic=True, color=COLORS.RED.value) nb_rows_in_table=len(rows),
font_titre = Font(name=FONT_NAME, bold=True, size=14) evaluations=[evaluation],
font_purple = Font(name=FONT_NAME, color=COLORS.PURPLE.value) styles=styles,
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 # etudiants
@ -209,6 +116,151 @@ def excel_feuille_saisie(
) )
def _insert_line_titles(
ws,
current_line,
nb_rows_in_table: int = 0,
evaluations: list[Evaluation] = None,
styles: dict = None,
) -> int:
"""Ligne(s) 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)
Renvoie nombre de lignes ajoutées (si plusieurs évaluations, indique les eval
ids au dessus des titres)
"""
# WIP
assert len(evaluations) == 1
evaluation = evaluations[0]
# 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 = "G"
ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")
# 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"]),
]
)
return 1 # WIP
def _build_styles() -> dict:
"""Déclare le styles excel"""
# bordures
side_thin = Side(border_style="thin", color=COLORS.BLACK.value)
border_top = Border(top=side_thin)
# fonds
fill_light_yellow = PatternFill(
patternType="solid", fgColor=COLORS.LIGHT_YELLOW.value
)
# 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(right=side_thin),
},
"dem": {
"font": Font(name=FONT_NAME, color=COLORS.BROWN.value),
"border": border_top,
},
"nom": { # style pour nom, prenom, groupe
"font": font_base,
"border": border_top,
},
"notes": {
"alignment": Alignment(horizontal="right"),
"font": Font(name=FONT_NAME, bold=True),
"number_format": FORMAT_GENERAL,
"fill": fill_light_yellow,
"border": border_top,
},
"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, description=""
) -> int:
"""Insère les lignes de titre de la feuille (suivies d'une ligne blanche)
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 jaunes)",
styles["explanation"],
prefix=[""],
)
ws.append_single_cell_row(
"Ne pas modifier les cases en mauve !", styles["explanation"], prefix=[""]
)
n += 2
# Nom du semestre
titre_annee = evaluation.moduleimpl.formsemestre.titre_annee()
ws.append_single_cell_row(
scu.unescape_html(titre_annee), styles["titres"], prefix=[""]
)
n += 1
# description 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): 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("Code notes", styles["titres"])])
ws.append_row( ws.append_row(
@ -254,24 +306,11 @@ def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None):
group_ids = group_ids or [] group_ids = group_ids or []
modimpl = evaluation.moduleimpl modimpl = evaluation.moduleimpl
formsemestre = modimpl.formsemestre formsemestre = modimpl.formsemestre
mod_responsable = sco_users.user_info(modimpl.responsable_id)
if evaluation.date_debut: if evaluation.date_debut:
indication_date = evaluation.date_debut.date().isoformat() indication_date = evaluation.date_debut.date().isoformat()
else: else:
indication_date = scu.sanitize_filename(evaluation.description)[:12] 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( groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids=group_ids, group_ids=group_ids,
@ -315,99 +354,110 @@ def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None):
} }
) )
eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}"
filename = f"notes_{eval_name}_{gr_title_filename}" filename = f"notes_{eval_name}_{gr_title_filename}"
xls = excel_feuille_saisie( xls = excel_feuille_saisie(evaluation, rows=rows)
evaluation, formsemestre.titre_annee(), description, rows=rows
)
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
def do_evaluation_upload_xls(): def do_evaluation_upload_xls() -> tuple[bool, str]:
""" """
Soumission d'un fichier XLS (evaluation_id, notefile) Soumission d'un fichier XLS (evaluation_id, notefile)
return:
ok: bool
msg: message diagonistic à affciher
""" """
args = scu.get_request_args() args = scu.get_request_args()
evaluation_id = int(args["evaluation_id"])
comment = args["comment"] comment = args["comment"]
evaluation = Evaluation.get_evaluation(evaluation_id) evaluation = Evaluation.get_evaluation(args["evaluation_id"])
# Check access (admin, respformation, responsable_id, ens) # Check access (admin, respformation, responsable_id, ens)
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}")
# #
diag, lines = sco_excel.excel_file_to_list(args["notefile"]) diag, rows = sco_excel.excel_file_to_list(args["notefile"])
try: try:
if not lines: if not rows:
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() raise InvalidNoteValue()
eval_id_str = lines[i][0].strip()[1:] row_title_idx, evaluations, evaluations_col_idx = _get_sheet_evaluations(
try: rows, evaluation=evaluation, diag=diag
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) # --- get notes -> list (etudid, value)
# ignore toutes les lignes ne commençant pas par ! # ignore toutes les lignes ne commençant pas par !
notes = [] notes_by_eval = defaultdict(
ni = i + 1 list
try: ) # { evaluation_id : [ (etudid, note_value), ... ] }
for line in lines[i + 1 :]: ni = row_title_idx + 1
if line: for row in rows[row_title_idx + 1 :]:
cell0 = line[0].strip() if row:
if cell0 and cell0[0] == "!": cell0 = row[0].strip()
etudid = cell0[1:] if cell0 and cell0[0] == "!":
if len(line) > 4: etudid = cell0[1:]
val = line[4].strip() # check etud
else: etud = Identite.get_etud(etudid, accept_none=True)
val = "" # ligne courte: cellule vide if not etud:
if etudid: diag.append(
notes.append((etudid, val)) f"étudiant id invalide en ligne {ni+1}"
ni += 1 ) # ligne excel à partir de 1
except Exception as exc: else:
diag.append( _read_notes_evaluations(
f"""Erreur: Ligne invalide ! (erreur ligne {ni})<br>{lines[ni]}""" row,
) etud,
raise InvalidNoteValue() from exc evaluations,
# -- check values notes_by_eval,
valid_notes, invalids, withoutnotes, absents, _ = sco_saisie_notes.check_notes( evaluations_col_idx,
notes, evaluation )
) ni += 1
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 = ( # -- Check values de chaque évaluation
sco_saisie_notes.notes_add( valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval = (
current_user, evaluation_id, valid_notes, comment _check_notes_evaluations(evaluations, notes_by_eval, diag)
)
) )
# news
# -- Enregistre les notes de chaque évaluation
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_by_eval[evaluation.id],
comment,
)
)
etudids_with_decisions |= set(etudids_with_decisions_eval)
msg = f"""<div class="diag-evaluation">
<ul>
<li><div>Module {evaluation.moduleimpl.module.code} :
évaluation {evaluation.description} {evaluation.descr_date()}
</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>
</li>
</ul>
"""
if messages:
msg += f"""<div class="warning">Attention&nbsp;:
<ul>
<li>{
'</li><li>'.join(messages)
}
</li>
</ul>
</div>"""
msg += """</div>"""
messages_by_eval[evaluation.id] = msg
# -- News
module: Module = evaluation.moduleimpl.module module: Module = evaluation.moduleimpl.module
status_url = url_for( status_url = url_for(
"notes.moduleimpl_status", "notes.moduleimpl_status",
@ -424,24 +474,11 @@ def do_evaluation_upload_xls():
max_frequency=30 * 60, # 30 minutes 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: if etudids_with_decisions:
msg += """<p class="warning">Important: il y avait déjà des décisions de jury msg += """<p class="warning"><b>Important:</b> il y avait déjà des décisions de jury
enregistrées, qui sont peut-être à revoir suite à cette modification !</p> enregistrées, qui sont à revoir suite à cette modification !</p>
""" """
return 1, msg return True, msg
except InvalidNoteValue: except InvalidNoteValue:
if diag: if diag:
@ -452,10 +489,119 @@ def do_evaluation_upload_xls():
) )
else: else:
msg = '<ul class="tf-msg"><li class="tf_msg">Une erreur est survenue</li></ul>' 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>" return False, msg + "<p>(pas de notes modifiées)</p>"
def saisie_notes_tableur(evaluation_id, group_ids=()): def _check_notes_evaluations(
evaluations: list[Evaluation],
notes_by_eval: dict[int, list[tuple[int, str]]],
diag: list[str],
) -> 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</p>"
)
if len(invalids) < 25:
etudsnames = [
Identite.get_etud(etudid).nom_prenom() for etudid in invalids
]
diag.append("Notes invalides pour: " + ", ".join(etudsnames))
else:
diag.append("Notes invalides pour plus de 25 étudiants")
raise InvalidNoteValue()
if etudids_non_inscrits:
diag.append(
f"""Erreur: la feuille contient {len(etudids_non_inscrits)
} étudiants non inscrits</p>"""
)
if len(etudids_non_inscrits) < 25:
diag.append(
"etudid invalides (inexistants ou non inscrits): "
+ ", ".join(str(etudid) for etudid in etudids_non_inscrits)
)
else:
diag.append("etudid invalides sur plus de 25 lignes")
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 _get_sheet_evaluations(
rows: list[list[str]], evaluation: Evaluation | 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 }
"""
# -- search eval code: first cell in 1st column beginning by "!"
eval_code = None
for i, row in enumerate(rows):
if not row:
diag.append("Erreur: format invalide (ligne vide ?)")
raise InvalidNoteValue()
eval_code = row[0].strip()
if eval_code.startswith("!"):
break
if not eval_code:
diag.append("Erreur: format invalide ! (pas de ligne evaluation_id)")
raise InvalidNoteValue()
try:
sheet_eval_id = int(eval_code[1:])
except ValueError:
sheet_eval_id = None
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}
def saisie_notes_tableur(evaluation_id: int, group_ids=()):
"""Saisie des notes via un fichier Excel""" """Saisie des notes via un fichier Excel"""
evaluation = Evaluation.query.get_or_404(evaluation_id) evaluation = Evaluation.query.get_or_404(evaluation_id)
moduleimpl_id = evaluation.moduleimpl.id moduleimpl_id = evaluation.moduleimpl.id
@ -562,9 +708,11 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
elif nf[0] == 1: elif nf[0] == 1:
updiag = do_evaluation_upload_xls() updiag = do_evaluation_upload_xls()
if updiag[0]: if updiag[0]:
H.append(updiag[1])
H.append( H.append(
f"""<p>Notes chargées.&nbsp;&nbsp;&nbsp; f"""
<div><b>Notes chargées.</b><div>
{updiag[1]}
<div>
<a class="stdlink" href="{ <a class="stdlink" href="{
url_for("notes.moduleimpl_status", url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id) scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
@ -578,7 +726,7 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
<a class="stdlink" href="{url_for("notes.saisie_notes", <a class="stdlink" href="{url_for("notes.saisie_notes",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id) scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}">Formulaire de saisie des notes</a> }">Formulaire de saisie des notes</a>
</p>""" </div>"""
) )
else: else:
H.append( H.append(

View File

@ -76,15 +76,17 @@ from app.scodoc.sco_utils import ModuleType
def convert_note_from_string( def convert_note_from_string(
note: str, note: str,
note_max, note_max: float,
note_min=scu.NOTES_MIN, note_min: float = scu.NOTES_MIN,
etudid: int = None, etudid: int = None,
absents: list[int] = None, absents: list[int] = None,
tosuppress: list[int] = None,
invalids: list[int] = None, invalids: list[int] = None,
): ) -> tuple[float, bool]:
"""converti une valeur (chaine saisie) vers une note numérique (float) """converti une valeur (chaine saisie) vers une note numérique (float)
Les listes absents, tosuppress et invalids sont modifiées Les listes absents et invalids sont modifiées.
Return:
note_value: float (valeur de la note ou code EXC, ATT, ...)
invalid: True si note invalide (eg hors barème)
""" """
invalid = False invalid = False
note_value = None note_value = None
@ -98,7 +100,6 @@ def convert_note_from_string(
note_value = scu.NOTES_ATTENTE note_value = scu.NOTES_ATTENTE
elif note[:3] == "SUP": elif note[:3] == "SUP":
note_value = scu.NOTES_SUPPRESS note_value = scu.NOTES_SUPPRESS
tosuppress.append(etudid)
else: else:
try: try:
note_value = float(note) note_value = float(note)
@ -111,12 +112,22 @@ def convert_note_from_string(
return note_value, invalid return note_value, invalid
def check_notes(notes: list[(int, float | str)], evaluation: Evaluation): def check_notes(
"""notes is a list of tuples (etudid, value) notes: list[(int, float | str)], evaluation: Evaluation
mod is the module (used to ckeck type, for malus) ) -> tuple[list[tuple[int, float]], list[int], list[int], list[int], list[int]]:
returns list of valid notes (etudid, float value) """Vérifie et converti les valeurs des notes pour une évaluation.
notes: list of tuples (etudid, value)
evaluation: target
Returns
valid_notes: list of valid notes (etudid, float value)
and 4 lists of etudid: and 4 lists of etudid:
etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress etudids_invalids : etudid avec notes invalides
etudids_without_notes: etudid sans notes (champs vides)
etudids_absents : etudid avec note ABS
etudids_non_inscrits : etudid non inscrits à ce module
(ne considère pas l'inscr. au semestre)
""" """
note_max = evaluation.note_max or 0.0 note_max = evaluation.note_max or 0.0
module: Module = evaluation.moduleimpl.module module: Module = evaluation.moduleimpl.module
@ -133,18 +144,25 @@ def check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
note_min = -20.0 note_min = -20.0
else: else:
raise ValueError("Invalid module type") # bug raise ValueError("Invalid module type") # bug
valid_notes = [] # liste (etudid, note) des notes ok (ou absent) # Vérifie inscription au module (même DEM/DEF)
etudids_invalids = [] # etudid avec notes invalides etudids_inscrits_mod = {
etudids_without_notes = [] # etudid sans notes (champs vides) i.etudid for i in evaluation.moduleimpl.query_inscriptions()
etudids_absents = [] # etudid absents }
etudid_to_suppress = [] # etudids avec ancienne note à supprimer valid_notes = []
etudids_invalids = []
etudids_without_notes = []
etudids_absents = []
etudids_non_inscrits = []
for etudid, note in notes: for etudid, note in notes:
note = str(note).strip().upper() if etudid not in etudids_inscrits_mod:
etudids_non_inscrits.append(etudid)
continue
try: try:
etudid = int(etudid) # etudid = int(etudid) #
except ValueError as exc: except ValueError as exc:
raise ScoValueError(f"Code étudiant ({etudid}) invalide") from exc raise ScoValueError(f"Code étudiant ({etudid}) invalide") from exc
note = str(note).strip().upper()
if note[:3] == "DEM": if note[:3] == "DEM":
continue # skip ! continue # skip !
if note: if note:
@ -154,7 +172,6 @@ def check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
note_min=note_min, note_min=note_min,
etudid=etudid, etudid=etudid,
absents=etudids_absents, absents=etudids_absents,
tosuppress=etudid_to_suppress,
invalids=etudids_invalids, invalids=etudids_invalids,
) )
if not invalid: if not invalid:
@ -166,7 +183,7 @@ def check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
etudids_invalids, etudids_invalids,
etudids_without_notes, etudids_without_notes,
etudids_absents, etudids_absents,
etudid_to_suppress, etudids_non_inscrits,
) )
@ -387,6 +404,8 @@ def notes_add(
Nota: Nota:
- si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log) - si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log)
Raise NoteProcessError si note invalide ou étudiant non inscrit.
Return: tuple (etudids_changed, nb_suppress, etudids_with_decision, messages) Return: tuple (etudids_changed, nb_suppress, etudids_with_decision, messages)
messages = list de messages d'avertissement/information pour l'utilisateur messages = list de messages d'avertissement/information pour l'utilisateur
@ -396,10 +415,7 @@ def notes_add(
messages = [] messages = []
# Vérifie inscription au module (même DEM/DEF) # Vérifie inscription au module (même DEM/DEF)
etudids_inscrits_mod = { etudids_inscrits_mod = {
x[0] i.etudid for i in evaluation.moduleimpl.query_inscriptions()
for x in sco_groups.do_evaluation_listeetuds_groups(
evaluation_id, getallstudents=True, include_demdef=True
)
} }
# Les étudiants inscrits au semestre et ceux "actifs" (ni DEM ni DEF) # Les étudiants inscrits au semestre et ceux "actifs" (ni DEM ni DEF)
etudids_inscrits_sem, etudids_actifs = ( etudids_inscrits_sem, etudids_actifs = (
@ -759,13 +775,17 @@ def get_sorted_etuds_notes(
e["val"] = scu.fmt_note( e["val"] = scu.fmt_note(
notes_db[etudid]["value"], fixed_precision_str=False notes_db[etudid]["value"], fixed_precision_str=False
) )
comment = notes_db[etudid]["comment"] user = (
if comment is None: User.query.get(notes_db[etudid]["uid"])
comment = "" if notes_db[etudid]["uid"]
e["explanation"] = "%s (%s) %s" % ( else None
notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M"), )
notes_db[etudid]["uid"], e["explanation"] = (
comment, f"""{
notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M")
} par {user.get_nomplogin() if user else '?'
} {(' : ' + notes_db[etudid]["comment"]) if notes_db[etudid]["comment"] else ''}
"""
) )
else: else:
e["val"] = "" e["val"] = ""

View File

@ -355,7 +355,7 @@ def external_ue_create_form(formsemestre_id: int, etudid: int):
else: else:
note = tf[2]["note"].strip().upper() note = tf[2]["note"].strip().upper()
note_value, invalid = sco_saisie_notes.convert_note_from_string( note_value, invalid = sco_saisie_notes.convert_note_from_string(
note, 20.0, etudid=etudid, absents=[], tosuppress=[], invalids=[] note, 20.0, etudid=etudid, absents=[], invalids=[]
) )
if invalid: if invalid:
return ( return (

View File

@ -46,7 +46,7 @@ Opérations:
""" """
import datetime import datetime
from flask import request from flask import g, request, url_for
from app.models import Evaluation, FormSemestre from app.models import Evaluation, FormSemestre
from app.scodoc.intervals import intervalmap from app.scodoc.intervals import intervalmap
@ -164,9 +164,12 @@ def evaluation_list_operations(evaluation_id: int):
columns_ids=columns_ids, columns_ids=columns_ids,
rows=operations, rows=operations,
html_sortable=False, html_sortable=False,
html_title=f"""<h2>Opérations sur l'évaluation {evaluation.description} { html_title=f"""<h2>Opérations sur l'évaluation
evaluation.date_debut.strftime("du %d/%m/%Y") if evaluation.date_debut else "(sans date)" <a class="stdlink" href="{
}</h2>""", url_for("notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}">{evaluation.description}</a>
{evaluation.date_debut.strftime("du %d/%m/%Y") if evaluation.date_debut else "(sans date)"}
</h2>""",
preferences=sco_preferences.SemPreferences( preferences=sco_preferences.SemPreferences(
evaluation.moduleimpl.formsemestre_id evaluation.moduleimpl.formsemestre_id
), ),

View File

@ -4908,4 +4908,9 @@ div.cas_etat_certif_ssl {
margin-bottom: 8px; margin-bottom: 8px;
font-style: italic; font-style: italic;
color: rgb(231, 0, 0); color: rgb(231, 0, 0);
}
.diag-evaluation {
color: green;
} }

View File

@ -204,7 +204,6 @@ def note_les_modules(doc: dict, formsemestre_titre: str = ""):
note_min=scu.NOTES_MIN, note_min=scu.NOTES_MIN,
etudid=etud.id, etudid=etud.id,
absents=[], absents=[],
tosuppress=[],
invalids=[], invalids=[],
) )
assert not invalid # valeur note invalide assert not invalid # valeur note invalide