forked from ScoDoc/ScoDoc
Saisie notes multi-évaluations. Closes #942.
This commit is contained in:
parent
94a77abc92
commit
9b825c0fb1
@ -267,10 +267,12 @@ class Evaluation(models.ScoDocModel):
|
||||
|
||||
@classmethod
|
||||
def get_evaluation(
|
||||
cls, evaluation_id: int | str, dept_id: int = None
|
||||
cls, evaluation_id: int | str, dept_id: int = None, accept_none=False
|
||||
) -> "Evaluation":
|
||||
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant."""
|
||||
from app.models import FormSemestre, ModuleImpl
|
||||
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant.
|
||||
Si accept_none, return None si l'id est invalide ou n'existe pas.
|
||||
"""
|
||||
from app.models import FormSemestre
|
||||
|
||||
if not isinstance(evaluation_id, int):
|
||||
try:
|
||||
@ -282,6 +284,8 @@ class Evaluation(models.ScoDocModel):
|
||||
query = cls.query.filter_by(id=evaluation_id)
|
||||
if dept_id is not None:
|
||||
query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id)
|
||||
if accept_none:
|
||||
return query.first()
|
||||
return query.first_or_404()
|
||||
|
||||
@classmethod
|
||||
|
@ -60,12 +60,12 @@ class COLORS(Enum):
|
||||
LIGHT_YELLOW = "FFFFFF99"
|
||||
|
||||
|
||||
# Un style est enregistré comme un dictionnaire qui précise la valeur d'un attribut dans la liste suivante:
|
||||
# Un style est enregistré comme un dictionnaire avec des attributs dans la liste suivante:
|
||||
# font, border, number_format, fill,...
|
||||
# (cf https://openpyxl.readthedocs.io/en/stable/styles.html#working-with-styles)
|
||||
|
||||
|
||||
def xldate_as_datetime(xldate, datemode=0):
|
||||
def xldate_as_datetime(xldate):
|
||||
"""Conversion d'une date Excel en datetime python
|
||||
Deux formats de chaîne acceptés:
|
||||
* JJ/MM/YYYY (chaîne naïve)
|
||||
@ -187,8 +187,8 @@ def excel_make_style(
|
||||
|
||||
class ScoExcelSheet:
|
||||
"""Représente une feuille qui peut être indépendante ou intégrée dans un ScoExcelBook.
|
||||
En application des directives de la bibliothèque sur l'écriture optimisée, l'ordre des opérations
|
||||
est imposé:
|
||||
En application des directives de la bibliothèque sur l'écriture optimisée,
|
||||
l'ordre des opérations est imposé:
|
||||
* instructions globales (largeur/maquage des colonnes et ligne, ...)
|
||||
* construction et ajout des cellules et ligne selon le sens de lecture (occidental)
|
||||
ligne de haut en bas et cellules de gauche à droite (i.e. A1, A2, .. B1, B2, ..)
|
||||
@ -199,7 +199,7 @@ class ScoExcelSheet:
|
||||
"""Création de la feuille. sheet_name
|
||||
-- le nom de la feuille default_style
|
||||
-- le style par défaut des cellules ws
|
||||
-- None si la feuille est autonome (dans ce cas elle crée son propre wb), sinon c'est la worksheet
|
||||
-- None si la feuille est autonome (elle crée son propre wb), sinon c'est la worksheet
|
||||
créée par le workbook propriétaire un workbook est créé et associé à cette feuille.
|
||||
"""
|
||||
# Le nom de la feuille ne peut faire plus de 31 caractères.
|
||||
@ -228,7 +228,8 @@ class ScoExcelSheet:
|
||||
fill=None,
|
||||
number_format=None,
|
||||
font=None,
|
||||
):
|
||||
) -> dict:
|
||||
"création d'un dict"
|
||||
style = {}
|
||||
if font is not None:
|
||||
style["font"] = font
|
||||
@ -393,7 +394,7 @@ class ScoExcelSheet:
|
||||
if isinstance(value, datetime.date):
|
||||
cell.data_type = "d"
|
||||
cell.number_format = FORMAT_DATE_DDMMYY
|
||||
elif isinstance(value, int) or isinstance(value, float):
|
||||
elif isinstance(value, (int, float)):
|
||||
cell.data_type = "n"
|
||||
else:
|
||||
cell.data_type = "s"
|
||||
@ -432,10 +433,11 @@ class ScoExcelSheet:
|
||||
Ce flux pourra ensuite être repris dans send_excel_file (classeur mono feille)
|
||||
ou pour la génération d'un classeur multi-feuilles
|
||||
"""
|
||||
for row in self.column_dimensions.keys():
|
||||
self.ws.column_dimensions[row] = self.column_dimensions[row]
|
||||
for row in self.row_dimensions.keys():
|
||||
self.ws.row_dimensions[row] = self.row_dimensions[row]
|
||||
for k, v in self.column_dimensions.items():
|
||||
self.ws.column_dimensions[k] = v
|
||||
|
||||
for k, v in self.row_dimensions.items():
|
||||
self.ws.row_dimensions[k] = self.row_dimensions[v]
|
||||
for row in self.rows:
|
||||
self.ws.append(row)
|
||||
|
||||
@ -529,17 +531,6 @@ def excel_file_to_list(filename):
|
||||
) from exc
|
||||
|
||||
|
||||
def excel_workbook_to_list(filename):
|
||||
try:
|
||||
return _excel_workbook_to_list(filename)
|
||||
except Exception as exc:
|
||||
raise ScoValueError(
|
||||
"""Le fichier xlsx attendu n'est pas lisible !
|
||||
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
|
||||
"""
|
||||
) from exc
|
||||
|
||||
|
||||
def _open_workbook(filelike, dump_debug=False) -> Workbook:
|
||||
"""Open document.
|
||||
On error, if dump-debug is True, dump data in /tmp for debugging purpose
|
||||
@ -559,7 +550,7 @@ def _open_workbook(filelike, dump_debug=False) -> Workbook:
|
||||
return workbook
|
||||
|
||||
|
||||
def _excel_to_list(filelike):
|
||||
def _excel_to_list(filelike) -> tuple[list, list[list]]:
|
||||
"""returns list of list"""
|
||||
workbook = _open_workbook(filelike)
|
||||
diag = [] # liste de chaines pour former message d'erreur
|
||||
@ -576,7 +567,7 @@ def _excel_to_list(filelike):
|
||||
return diag, matrix
|
||||
|
||||
|
||||
def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list]:
|
||||
def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list[list]]:
|
||||
"""read a spreadsheet sheet, and returns:
|
||||
- diag : a list of strings (error messages aimed at helping the user)
|
||||
- a list of lists: the spreadsheet cells
|
||||
@ -609,14 +600,21 @@ def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list]
|
||||
return diag, matrix
|
||||
|
||||
|
||||
def _excel_workbook_to_list(filelike):
|
||||
def excel_workbook_to_list(filelike):
|
||||
"""Lit un classeur (workbook): chaque feuille est lue
|
||||
et est convertie en une liste de listes.
|
||||
Returns:
|
||||
- diag : a list of strings (error messages aimed at helping the user)
|
||||
- a list of lists: the spreadsheet cells
|
||||
"""
|
||||
workbook = _open_workbook(filelike)
|
||||
try:
|
||||
workbook = _open_workbook(filelike)
|
||||
except Exception as exc:
|
||||
raise ScoValueError(
|
||||
"""Le fichier xlsx attendu n'est pas lisible !
|
||||
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
|
||||
"""
|
||||
) from exc
|
||||
diag = [] # liste de chaines pour former message d'erreur
|
||||
if len(workbook.sheetnames) < 1:
|
||||
diag.append("Aucune feuille trouvée dans le classeur !")
|
||||
@ -631,6 +629,7 @@ def _excel_workbook_to_list(filelike):
|
||||
return diag, matrix_list
|
||||
|
||||
|
||||
# TODO déplacer dans un autre fichier
|
||||
def excel_feuille_listeappel(
|
||||
sem,
|
||||
groupname,
|
||||
|
@ -23,6 +23,21 @@
|
||||
##############################################################################
|
||||
|
||||
"""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
|
||||
@ -30,13 +45,14 @@ 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, request, url_for
|
||||
from flask import g, render_template, request, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
from app.models import Evaluation, FormSemestre, Identite, Module, ScolarNews
|
||||
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,
|
||||
@ -48,6 +64,7 @@ from app.scodoc import (
|
||||
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"
|
||||
|
||||
@ -180,7 +197,7 @@ def _insert_line_titles(
|
||||
ws.append_row(cells)
|
||||
|
||||
# Calcul largeur colonnes (actuellement pour feuille import multi seulement)
|
||||
# Le facteur prend en compte la tailel du font (14)
|
||||
# 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
|
||||
@ -525,124 +542,89 @@ def generate_excel_import_notes(
|
||||
return ws.generate(column_widths=column_widths)
|
||||
|
||||
|
||||
def do_evaluation_upload_xls() -> tuple[bool, str]:
|
||||
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 diagonistic à affciher
|
||||
msg: message diagnostic à affciher
|
||||
"""
|
||||
args = scu.get_request_args()
|
||||
comment = args["comment"]
|
||||
evaluation = Evaluation.get_evaluation(args["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, rows = sco_excel.excel_file_to_list(args["notefile"])
|
||||
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, diag=diag
|
||||
rows, evaluation=evaluation, formsemestre=formsemestre, diag=diag
|
||||
)
|
||||
# --- get notes -> list (etudid, value)
|
||||
# ignore toutes les lignes ne commençant pas par !
|
||||
notes_by_eval = defaultdict(
|
||||
list
|
||||
) # { evaluation_id : [ (etudid, note_value), ... ] }
|
||||
ni = row_title_idx + 1
|
||||
for row in rows[row_title_idx + 1 :]:
|
||||
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
|
||||
|
||||
# -- 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)
|
||||
# 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
|
||||
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,
|
||||
)
|
||||
with sco_cache.DeferredSemCacheManager():
|
||||
messages_by_eval, etudids_with_decisions = _record_notes_evaluations(
|
||||
evaluations, notes_by_eval, comment, diag, rows=rows
|
||||
)
|
||||
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 :
|
||||
<ul>
|
||||
<li>{
|
||||
'</li><li>'.join(messages)
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</div>"""
|
||||
msg += """</div>"""
|
||||
messages_by_eval[evaluation.id] = msg
|
||||
|
||||
# -- 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,
|
||||
)
|
||||
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=evaluation.moduleimpl_id,
|
||||
text=f"""Chargement notes dans <a href="{status_url}">{
|
||||
module.titre or module.code}</a>""",
|
||||
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 = (
|
||||
"""<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:
|
||||
@ -657,10 +639,101 @@ def do_evaluation_upload_xls() -> tuple[bool, str]:
|
||||
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.
|
||||
@ -678,29 +751,40 @@ def _check_notes_evaluations(
|
||||
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>"
|
||||
)
|
||||
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
|
||||
]
|
||||
diag.append("Notes invalides pour: " + ", ".join(etudsnames))
|
||||
msg += ", ".join(etudsnames)
|
||||
else:
|
||||
diag.append("Notes invalides pour plus de 25 étudiants")
|
||||
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 non inscrits</p>"""
|
||||
} étudiants inexistants ou non inscrits à l'évaluation
|
||||
{evaluation.moduleimpl.module.code}
|
||||
{evaluation.description}
|
||||
<ul>{msg}</ul>
|
||||
"""
|
||||
)
|
||||
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
|
||||
|
||||
@ -724,8 +808,61 @@ def _read_notes_evaluations(
|
||||
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, diag: list[str] = None
|
||||
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
|
||||
@ -735,35 +872,38 @@ def _get_sheet_evaluations(
|
||||
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)
|
||||
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 ?)")
|
||||
|
||||
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()
|
||||
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}
|
||||
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=()):
|
||||
@ -840,7 +980,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
|
||||
<span class="titredivsaisienote">Étape 2 : chargement d'un fichier de notes</span>""" # '
|
||||
)
|
||||
|
||||
nf = TrivialFormulator(
|
||||
tf = TrivialFormulator(
|
||||
request.base_url,
|
||||
scu.get_request_args(),
|
||||
(
|
||||
@ -861,23 +1001,27 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
|
||||
formid="notesfile",
|
||||
submitlabel="Télécharger",
|
||||
)
|
||||
if nf[0] == 0:
|
||||
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(nf[1])
|
||||
elif nf[0] == -1:
|
||||
H.append(tf[1])
|
||||
elif tf[0] == -1:
|
||||
H.append("<p>Annulation</p>")
|
||||
elif nf[0] == 1:
|
||||
updiag = do_evaluation_upload_xls()
|
||||
if updiag[0]:
|
||||
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>
|
||||
{updiag[1]}
|
||||
{diagnostic_msg}
|
||||
</div>
|
||||
<a class="stdlink" href="{
|
||||
url_for("notes.moduleimpl_status",
|
||||
@ -898,7 +1042,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
|
||||
H.append(
|
||||
f"""
|
||||
<p class="redboldtext">Notes non chargées !</p>
|
||||
{updiag[1]}
|
||||
{diagnostic_msg}
|
||||
<p><a class="stdlink" href="{url_for("notes.saisie_notes_tableur",
|
||||
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
|
||||
}">
|
||||
@ -955,7 +1099,8 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
|
||||
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;
|
||||
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>
|
||||
@ -977,3 +1122,18 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
@ -156,7 +156,12 @@ def check_notes(
|
||||
|
||||
for etudid, note in notes:
|
||||
if etudid not in etudids_inscrits_mod:
|
||||
etudids_non_inscrits.append(etudid)
|
||||
# Si inscrit au formsemestre mais pas au module,
|
||||
# accepte note "NI" uniquement (pour les imports excel multi-éval)
|
||||
if (
|
||||
etudid not in evaluation.moduleimpl.formsemestre.etudids_actifs()[0]
|
||||
) or note != "NI":
|
||||
etudids_non_inscrits.append(etudid)
|
||||
continue
|
||||
try:
|
||||
etudid = int(etudid) #
|
||||
@ -388,6 +393,27 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
|
||||
return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer()
|
||||
|
||||
|
||||
def _check_inscription(
|
||||
etudid: int,
|
||||
etudids_inscrits_sem: list[int],
|
||||
etudids_inscrits_mod: set[int],
|
||||
messages: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Vérifie inscription de etudid au moduleimpl et au semestre, et
|
||||
- si étudiant non inscrit au semestre ou au module: lève NoteProcessError
|
||||
"""
|
||||
msg_err = ""
|
||||
if etudid not in etudids_inscrits_sem:
|
||||
msg_err = "non inscrit au semestre"
|
||||
elif etudid not in etudids_inscrits_mod:
|
||||
msg_err = "non inscrit au module"
|
||||
if msg_err:
|
||||
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
|
||||
msg = f"étudiant {etud.nomprenom if etud else etudid} {msg_err}"
|
||||
log(f"notes_add: {etudid} {msg}: aborting")
|
||||
raise NoteProcessError(msg)
|
||||
|
||||
|
||||
def notes_add(
|
||||
user: User,
|
||||
evaluation_id: int,
|
||||
@ -424,22 +450,8 @@ def notes_add(
|
||||
for etudid, value in notes:
|
||||
|
||||
if check_inscription:
|
||||
msg_err, msg_warn = "", ""
|
||||
if etudid not in etudids_inscrits_sem:
|
||||
msg_err = "non inscrit au semestre"
|
||||
elif etudid not in etudids_inscrits_mod:
|
||||
msg_err = "non inscrit au module"
|
||||
elif etudid not in etudids_actifs:
|
||||
# DEM ou DEF
|
||||
msg_warn = "démissionnaire ou défaillant (note enregistrée)"
|
||||
if msg_err or msg_warn:
|
||||
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
|
||||
msg = f"étudiant {etud.nomprenom if etud else etudid} {msg_err or msg_warn}"
|
||||
if msg_err:
|
||||
log(f"notes_add: {etudid} non inscrit ou DEM/DEF: aborting")
|
||||
raise NoteProcessError(msg)
|
||||
if msg_warn:
|
||||
messages.append(msg)
|
||||
_check_inscription(etudid, etudids_inscrits_sem, etudids_inscrits_mod)
|
||||
|
||||
if (value is not None) and not isinstance(value, float):
|
||||
log(f"notes_add: {etudid} valeur de note invalide ({value}): aborting")
|
||||
etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
|
||||
@ -470,14 +482,26 @@ def notes_add(
|
||||
date=now,
|
||||
do_it=do_it,
|
||||
)
|
||||
|
||||
if suppressed:
|
||||
nb_suppress += 1
|
||||
|
||||
if changed:
|
||||
etudids_changed.append(etudid)
|
||||
# si change sur DEM/DEF ajoute message warning aux messages
|
||||
if etudid not in etudids_actifs: # DEM ou DEF
|
||||
etud = (
|
||||
Identite.query.get(etudid) if isinstance(etudid, int) else None
|
||||
)
|
||||
messages.append(
|
||||
f"""étudiant {etud.nomprenom if etud else etudid
|
||||
} démissionnaire ou défaillant (note enregistrée)"""
|
||||
)
|
||||
|
||||
if res.etud_has_decision(etudid, include_rcues=False):
|
||||
etudids_with_decision.append(etudid)
|
||||
except NotImplementedError as exc: # XXX
|
||||
|
||||
except Exception as exc:
|
||||
log("*** exception in notes_add")
|
||||
if do_it:
|
||||
cnx.rollback() # abort
|
||||
@ -485,10 +509,12 @@ def notes_add(
|
||||
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
|
||||
sco_cache.EvaluationCache.delete(evaluation_id)
|
||||
raise ScoException from exc
|
||||
|
||||
if do_it:
|
||||
cnx.commit()
|
||||
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
|
||||
sco_cache.EvaluationCache.delete(evaluation_id)
|
||||
|
||||
return etudids_changed, nb_suppress, etudids_with_decision, messages
|
||||
|
||||
|
||||
|
@ -72,10 +72,23 @@ div.scobox.explanation {
|
||||
background-color: var(--sco-color-background);
|
||||
}
|
||||
|
||||
div.scobox.success div.scobox-title {
|
||||
color: white;
|
||||
background-color: darkgreen;
|
||||
}
|
||||
|
||||
div.scobox.failure div.scobox-title {
|
||||
color: white;
|
||||
background-color: #c50000;
|
||||
}
|
||||
|
||||
div.scobox div.scobox-title {
|
||||
font-size: 120%;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 8px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
div.scobox-buttons {
|
||||
|
@ -4,7 +4,9 @@
|
||||
{% block styles %}
|
||||
{{super()}}
|
||||
<style>
|
||||
|
||||
div.vspace {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -35,7 +37,7 @@ Cette page permet d'importer des notes dans tout ou partie des évaluations du s
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help" style="margin-top: 24px;">Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le
|
||||
<div class="help vspace">Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le
|
||||
ci-dessous.
|
||||
Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est jamais montré aux étudiants.
|
||||
</div>
|
||||
@ -47,4 +49,32 @@ Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est
|
||||
|
||||
</div>
|
||||
|
||||
<div class="help vspace">
|
||||
À l'<b>étape 2</b>, indiquer le fichier Excel
|
||||
<em>téléchargé à l'étape 1</em> et dans lequel on a saisi des notes.
|
||||
<div class="vspace">
|
||||
<b>Remarques :</b>
|
||||
<ul>
|
||||
<li>Le fichier Excel <em>doit impérativement être celui chargé à
|
||||
l'étape 1 pour ce semestre</em>. Il n'est pas possible d'utiliser
|
||||
une liste d'appel ou autre document Excel téléchargé d'une autre page.
|
||||
</li>
|
||||
<li>Ne pas supprimer les lignes et colonnes cachées, qui
|
||||
contiennent des codes. Le fichier exporté contient toutles les
|
||||
évaluations du semestre, vous pouvez au besoin supprimer certaines
|
||||
colonnes d'évaluations.
|
||||
</li>
|
||||
|
||||
<li>Le fichier 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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
54
app/templates/formsemestre/import_notes_after.j2
Normal file
54
app/templates/formsemestre/import_notes_after.j2
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends "sco_page.j2" %}
|
||||
{% import 'wtf.j2' as wtf %}
|
||||
|
||||
{% block styles %}
|
||||
{{super()}}
|
||||
<style>
|
||||
.import-diag ul.tf-msg {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.diag-change {
|
||||
font-weight: bold;
|
||||
}
|
||||
.diag-nochange {
|
||||
color: gray;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block app_content %}
|
||||
<h2>Import de notes dans les évaluations du semestre</h2>
|
||||
|
||||
<div class="scobox {{ 'success' if ok else 'failure' }}">
|
||||
<div class="scobox-title">
|
||||
{% if ok %}
|
||||
Notes importées avec succès
|
||||
{% else %}
|
||||
Erreur: aucune note chargée
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="import-diag">
|
||||
{{ diagnostic_msg | safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scobox">
|
||||
|
||||
<ul>
|
||||
<li><a class="stdlink"
|
||||
href="{{url_for('notes.formsemestre_recapcomplet',
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id,
|
||||
tabformat='evals')}}">
|
||||
Tableau de <em>toutes</em> les notes du semestre
|
||||
</a>
|
||||
</li>
|
||||
<li><a class="stdlink"
|
||||
href="{{url_for('notes.formsemestre_import_notes',
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id)}}">
|
||||
Importer d'autres notes
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -1870,15 +1870,17 @@ def formsemestre_import_notes(formsemestre_id: int):
|
||||
# Handle file upload and form processing
|
||||
notefile = form.notefile.data
|
||||
comment = form.comment.data
|
||||
# Save the file and process form data here
|
||||
raise ScoValueError("unimplemented")
|
||||
return redirect(url_for("index"))
|
||||
#
|
||||
return sco_saisie_excel.formsemestre_import_notes(
|
||||
formsemestre, notefile, comment
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"formsemestre/import_notes.j2",
|
||||
evaluations=formsemestre.get_evaluations(),
|
||||
form=form,
|
||||
formsemestre=formsemestre,
|
||||
title="Importation des notes",
|
||||
sco=ScoData(formsemestre=formsemestre),
|
||||
)
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
SCOVERSION = "9.6.992"
|
||||
SCOVERSION = "9.7.0"
|
||||
|
||||
SCONAME = "ScoDoc"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user