WIP: Import de toutes les notes d'un semestre: génération de la feuille. Début de #942.

This commit is contained in:
Emmanuel Viennet 2024-07-11 19:45:52 +02:00
parent 4214a53a4e
commit 62e4481c77
6 changed files with 353 additions and 68 deletions

View File

@ -33,6 +33,7 @@ import io
import time
from enum import Enum
from tempfile import NamedTemporaryFile
from typing import AnyStr
import openpyxl.utils.datetime
from openpyxl.styles.numbers import FORMAT_NUMBER_00, FORMAT_GENERAL, FORMAT_DATE_DDMMYY
@ -250,10 +251,10 @@ class ScoExcelSheet:
return idx
if idx < 26: # one letter key
return chr(idx + 65)
else: # two letters AA..ZZ
first = (idx // 26) + 66
second = (idx % 26) + 65
return "" + chr(first) + chr(second)
# two letters AA..ZZ
first = (idx // 26) + 64
second = (idx % 26) + 65
return "" + chr(first) + chr(second)
def set_column_dimension_width(self, cle=None, value: int | str | list = 21):
"""Détermine la largeur d'une colonne.
@ -295,7 +296,7 @@ class ScoExcelSheet:
self.ws.row_dimensions[cle].hidden = value
def set_column_dimension_hidden(self, cle, value):
"""Masque ou affiche une ligne.
"""Masque ou affiche une colonne.
cle -- identifie la colonne (1...)
value -- boolean (vrai = colonne cachée)
"""
@ -331,6 +332,7 @@ class ScoExcelSheet:
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)
@ -339,10 +341,13 @@ class ScoExcelSheet:
)
# Set the column widths based on the maximum length found
# (nb: the width is expressed in characters, in the default font)
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):
def make_cell(
self, value: any = None, style: dict = None, comment=None
) -> WriteOnlyCell:
"""Construit une cellule.
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é
@ -434,7 +439,7 @@ class ScoExcelSheet:
for row in self.rows:
self.ws.append(row)
def generate(self, column_widths=None):
def generate(self, column_widths=None) -> AnyStr:
"""génération d'un classeur mono-feuille"""
# this method makes sense for standalone worksheet (else call workbook.generate())
if self.wb is None: # embeded sheet

View File

@ -427,6 +427,12 @@ def formsemestre_status_menubar(formsemestre: FormSemestre | None) -> str:
"endpoint": "notes.formsemestre_list_saisies_notes",
"args": {"formsemestre_id": formsemestre_id},
},
{
"title": "Importer les notes",
"endpoint": "notes.formsemestre_import_notes",
"args": {"formsemestre_id": formsemestre_id},
"enabled": formsemestre.est_chef_or_diretud(),
},
]
menu_jury = [
{

View File

@ -25,17 +25,20 @@
"""Fichier excel de saisie des notes
"""
from collections import defaultdict
from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
from typing import AnyStr
from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side
from openpyxl.styles.numbers import FORMAT_GENERAL
from flask import flash, g, request, url_for
from flask import g, request, url_for
from flask_login import current_user
from app.models import Evaluation, Identite, Module, ScolarNews
from app.models import Evaluation, FormSemestre, Identite, Module, ScolarNews
from app.scodoc.sco_excel import COLORS, ScoExcelSheet
from app.scodoc import (
html_sco_header,
sco_evaluations,
sco_evaluation_db,
sco_excel,
sco_groups,
sco_groups_view,
@ -49,14 +52,14 @@ from app.scodoc.TrivialFormulator import TrivialFormulator
FONT_NAME = "Arial"
def excel_feuille_saisie(evaluation: "Evaluation", rows: list[dict]):
"""Genere feuille excel pour saisie des notes.
E: evaluation (dict)
lines: liste de tuples
def excel_feuille_saisie(evaluation: "Evaluation", rows: list[dict]) -> AnyStr:
"""Génère feuille excel pour saisie des notes dans l'evaluation
- evaluation
- rows: liste de dict
(etudid, nom, prenom, etat, groupe, val, explanation)
Return excel data.
"""
sheet_name = "Saisie notes"
ws = ScoExcelSheet(sheet_name)
ws = ScoExcelSheet("Saisie notes")
styles = _build_styles()
nb_lines_titles = _insert_top_title(ws, styles, evaluation=evaluation)
@ -100,6 +103,8 @@ def excel_feuille_saisie(evaluation: "Evaluation", rows: list[dict]):
# explication en bas
_insert_bottom_help(ws, styles)
# Hide column A (codes étudiants)
ws.set_column_dimension_hidden("A", True) # colonne etudid cachée
ws.set_column_dimension_hidden("G", True) # colonne NIP cachée
@ -122,27 +127,46 @@ def _insert_line_titles(
nb_rows_in_table: int = 0,
evaluations: list[Evaluation] = None,
styles: dict = None,
) -> int:
"""Ligne(s) des titres, avec filtre auto excel.
multi_eval=False,
) -> dict:
"""Insère ligne des titres, avec filtre auto excel.
current_line : nb de lignes déjà dans le tableau
nb_rows_in_table: nombre de ligne dans tableau à trier pour le filtre (nb d'étudiants)
Renvoie nombre de lignes ajoutées (si plusieurs évaluations, indique les eval
ids au dessus des titres)
multi_eval: si vrai, titres pour plusieurs évaluations (feuille import semestre)
Return dict giving (title) column widths
"""
# WIP
assert len(evaluations) == 1
evaluation = evaluations[0]
# La colonne de gauche (utilisée pour cadrer le filtre)
# est G si une seule eval
right_column = ScoExcelSheet.i2col(3 + len(evaluations)) if multi_eval else "G"
# Filtre auto excel sur colonnes
filter_top = current_line + 1
filter_bottom = current_line + 1 + nb_rows_in_table
filter_left = "A" # important: le code etudid en col A doit être trié en même temps
filter_right = "G"
filter_right = right_column
ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")
# Code et titres colonnes
ws.append_row(
[
if multi_eval:
cells = [
ws.make_cell("", styles["read-only"]),
ws.make_cell("Nom", styles["titres"]),
ws.make_cell("Prénom", styles["titres"]),
ws.make_cell("Groupe", styles["titres"]),
] + [
ws.make_cell(
f"""{evaluation.moduleimpl.module.code
} : {evaluation.description} (/{(evaluation.note_max or 0.0):g})""",
styles["titres"],
comment=f"""{evaluation.descr_date()
}, notes sur {(evaluation.note_max or 0.0):g}""",
)
for evaluation in evaluations
]
else:
evaluation = evaluations[0]
cells = [
ws.make_cell(f"!{evaluation.id}", styles["read-only"]),
ws.make_cell("Nom", styles["titres"]),
ws.make_cell("Prénom", styles["titres"]),
@ -153,21 +177,35 @@ def _insert_line_titles(
ws.make_cell("Remarque", styles["titres"]),
ws.make_cell("NIP", styles["titres"]),
]
)
return 1 # WIP
ws.append_row(cells)
# Calcul largeur colonnes (actuellement pour feuille import multi seulement)
# Le facteur prend en compte la tailel du font (14)
font_size_factor = 1.25
column_widths = {
ScoExcelSheet.i2col(idx): (len(str(cell.value)) + 2.0) * font_size_factor
for idx, cell in enumerate(cells)
}
# Force largeurs des colonnes noms/prénoms/groupes
column_widths["B"] = 26.0 # noms
column_widths["C"] = 26.0 # noms
column_widths["D"] = 26.0 # groupes
return column_widths
def _build_styles() -> dict:
"""Déclare le styles excel"""
"""Déclare les styles excel"""
# bordures
side_thin = Side(border_style="thin", color=COLORS.BLACK.value)
side_thin = Side(border_style="thin", color=Color(rgb="666688"))
border_top = Border(top=side_thin)
border_box = Border(
top=side_thin, left=side_thin, bottom=side_thin, right=side_thin
)
# fonds
fill_light_yellow = PatternFill(
patternType="solid", fgColor=COLORS.LIGHT_YELLOW.value
)
fill_saisie_notes = PatternFill(patternType="solid", fgColor=Color(rgb="E3FED4"))
# styles
font_base = Font(name=FONT_NAME, size=12)
@ -179,22 +217,22 @@ def _build_styles() -> dict:
},
"read-only": { # cells read-only
"font": Font(name=FONT_NAME, color=COLORS.PURPLE.value),
"border": Border(right=side_thin),
"border": border_box,
},
"dem": {
"font": Font(name=FONT_NAME, color=COLORS.BROWN.value),
"border": border_top,
"border": border_box,
},
"nom": { # style pour nom, prenom, groupe
"font": font_base,
"border": border_top,
"border": border_box,
},
"notes": {
"alignment": Alignment(horizontal="right"),
"font": Font(name=FONT_NAME, bold=True),
"font": Font(name=FONT_NAME, bold=False),
"number_format": FORMAT_GENERAL,
"fill": fill_light_yellow,
"border": border_top,
"fill": fill_saisie_notes,
"border": border_box,
},
"comment": {
"font": Font(name=FONT_NAME, size=9, color=COLORS.BLUE.value),
@ -204,9 +242,15 @@ def _build_styles() -> dict:
def _insert_top_title(
ws, styles: dict, evaluation: Evaluation = None, description=""
ws,
styles: dict,
evaluation: Evaluation | None = None,
formsemestre: FormSemestre | None = None,
description="",
) -> int:
"""Insère les lignes de titre de la feuille (suivies d'une ligne blanche)
"""Insère les lignes de titre de la feuille (suivies d'une ligne blanche).
Si evaluation, indique son titre.
Si formsemestre, indique son titre.
renvoie le nb de lignes insérées
"""
n = 0
@ -219,42 +263,54 @@ def _insert_top_title(
n += 1
# lignes d'instructions
ws.append_single_cell_row(
"Saisir les notes dans la colonne E (cases jaunes)",
(
"Saisir les notes dans la colonne E (cases vertes)"
if evaluation
else "Saisir les notes de chaque évaluation"
),
styles["explanation"],
prefix=[""],
)
ws.append_single_cell_row(
"Ne pas modifier les cases en mauve !", styles["explanation"], prefix=[""]
"Ne pas modifier les lignes et colonnes masquées (en mauve)!",
styles["explanation"],
prefix=[""],
)
n += 2
# Nom du semestre
titre_annee = evaluation.moduleimpl.formsemestre.titre_annee()
titre_annee = (
evaluation.moduleimpl.formsemestre.titre_annee()
if evaluation
else (formsemestre.titre_annee() if formsemestre else "")
)
ws.append_single_cell_row(
scu.unescape_html(titre_annee), styles["titres"], prefix=[""]
)
n += 1
# description evaluation
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}"""
if evaluation:
date_str = (
f"""du {evaluation.date_debut.strftime(scu.DATE_FMT)}"""
if evaluation.date_debut
else "(sans date)"
)
eval_titre = f"""{evaluation.description if evaluation.description else "évaluation"
} {date_str}"""
mod_responsable = sco_users.user_info(evaluation.moduleimpl.responsable_id)
description = f"""{eval_titre} en {evaluation.moduleimpl.module.abbrev or ""} ({
evaluation.moduleimpl.module.code
}) resp. {mod_responsable["prenomnom"]}"""
ws.append_single_cell_row(
scu.unescape_html(description), styles["titres"], prefix=[""]
)
ws.append_single_cell_row(
f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})",
styles["base"],
prefix=[""],
)
n += 2
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
@ -300,7 +356,9 @@ def _insert_bottom_help(ws, styles: dict):
)
def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None):
def feuille_saisie_notes(
evaluation_id, group_ids: list[int] = None
): # TODO ré-écrire et passer dans notes.py
"""Vue: document Excel pour saisie notes dans l'évaluation et les groupes indiqués"""
evaluation = Evaluation.get_evaluation(evaluation_id)
group_ids = group_ids or []
@ -360,6 +418,113 @@ def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None):
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
def excel_feuille_import(formsemestre: FormSemestre) -> AnyStr:
"""Génère feuille pour import toutes notes dans ce semestre,
avec une colonne par évaluation.
Return excel data
"""
evaluations = formsemestre.get_evaluations()
etudiants = formsemestre.get_inscrits(include_demdef=True, order=True)
rows = [{"etud": etud} for etud in etudiants]
# Liste les étudiants et leur note à chaque évaluation
for evaluation in evaluations:
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
inscrits_module = {ins.etudid for ins in evaluation.moduleimpl.inscriptions}
for row in rows:
etud = row["etud"]
if not etud.id in inscrits_module:
note_str = "NI" # non inscrit à ce module
else:
val = notes_db.get(etud.id, {}).get("value", "")
# export numérique excel
note_str = scu.fmt_note(val, keep_numeric=True)
row[evaluation.id] = note_str
#
return generate_excel_import_notes(evaluations, rows)
def generate_excel_import_notes(
evaluations: list[Evaluation], rows: list[dict]
) -> AnyStr:
"""Génère la feuille excel pour l'import multi-évaluations.
On distingue ces feuille de celles utilisées pour une seule éval par la présence
de la valeur "MULTIEVAL" en tête de la colonne A (qui est invisible).
"""
ws = ScoExcelSheet("Import notes")
styles = _build_styles()
formsemestre: FormSemestre = (
evaluations[0].moduleimpl.formsemestre if evaluations else None
)
nb_lines_titles = _insert_top_title(ws, styles, formsemestre=formsemestre)
# codes évaluations
ws.append_row(
[
ws.make_cell(x, styles["read-only"])
for x in [
"MULTIEVAL",
"",
"",
"",
]
]
+ [evaluation.id for evaluation in evaluations]
)
column_widths = _insert_line_titles(
ws,
nb_lines_titles + 1,
nb_rows_in_table=len(rows),
evaluations=evaluations,
styles=styles,
multi_eval=True,
)
if not formsemestre: # aucune évaluation
rows = []
# etudiants
etuds_inscriptions = formsemestre.etuds_inscriptions
for row in rows:
etud: Identite = row["etud"]
st = styles["nom"]
match etuds_inscriptions[etud.id].etat:
case scu.INSCRIT:
groups = sco_groups.get_etud_groups(etud.id, formsemestre.id)
groupe_ou_etat = sco_groups.listgroups_abbrev(groups)
case scu.DEMISSION:
st = styles["dem"]
groupe_ou_etat = "DEM"
case scu.DEF:
groupe_ou_etat = "DEF"
st = styles["dem"]
case _:
groupe_ou_etat = "?" # état inconnu
ws.append_row(
[
ws.make_cell("!" + str(etud.id), styles["read-only"]),
ws.make_cell(etud.nom_disp(), st),
ws.make_cell(etud.prenom_str, st),
ws.make_cell(groupe_ou_etat, st),
]
+ [
ws.make_cell(row[evaluation.id], styles["notes"])
for evaluation in evaluations
]
)
# ligne blanche
ws.append_blank_row()
# explication en bas
_insert_bottom_help(ws, styles)
# Hide column A (codes étudiants)
ws.set_column_dimension_hidden("A", True)
# Hide row codes evaluations
ws.set_row_dimension_hidden(nb_lines_titles + 1, True)
return ws.generate(column_widths=column_widths)
def do_evaluation_upload_xls() -> tuple[bool, str]:
"""
Soumission d'un fichier XLS (evaluation_id, notefile)
@ -652,7 +817,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
H.append(
f"""<div class="saisienote_etape1">
<span class="titredivsaisienote">Etape 1 : </span>
<span class="titredivsaisienote">Étape 1 : </span>
<ul>
<li><a class="stdlink" href="feuille_saisie_notes?evaluation_id={evaluation_id}&{
groups_infos.groups_query_args}"
@ -672,7 +837,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
H.append(
"""<div class="saisienote_etape2">
<span class="titredivsaisienote">Etape 2 : chargement d'un fichier de notes</span>""" # '
<span class="titredivsaisienote">Étape 2 : chargement d'un fichier de notes</span>""" # '
)
nf = TrivialFormulator(

View File

@ -0,0 +1,50 @@
{% extends "sco_page.j2" %}
{% import 'wtf.j2' as wtf %}
{% block styles %}
{{super()}}
<style>
</style>
{% endblock %}
{% block app_content %}
<h2>Import de notes dans les évaluations du semestre</h2>
<div class="help">
Cette page permet d'importer des notes dans tout ou partie des évaluations du semestre.
</div>
<div>
Il y a <a class="stdlink" href="{{
url_for('notes.evaluations_recap', scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id )
}}">{{ evaluations | length }} évaluations</a> définies dans ce semestre.
</div>
<div class="saisienote_etape1">
<span class="titredivsaisienote">Étape 1 : </span>
<ul>
<li><a class="stdlink" href="{{
url_for( 'notes.feuille_import_notes', scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id )
}}" id="lnk_feuille_saisie">
obtenir le fichier tableur à remplir
</a>
</li>
</ul>
</div>
<div class="help" style="margin-top: 24px;">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>
<div class="saisienote_etape2">
<span class="titredivsaisienote">Étape 2 : chargement du fichier de notes</span>
{{ wtf.quick_form(form, enctype="multipart/form-data") }}
</div>
{% endblock %}

View File

@ -38,6 +38,10 @@ import flask
from flask import flash, redirect, render_template, url_for
from flask import g, request
from flask_login import current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed
from wtforms.validators import DataRequired, Length
from wtforms import FileField, HiddenField, StringField, SubmitField
from app import db, log, send_scodoc_alarm
from app import models
@ -1835,6 +1839,61 @@ sco_publish(
)
@bp.route("/formsemestre_import_notes/<int:formsemestre_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView) # controle contextuel
def formsemestre_import_notes(formsemestre_id: int):
"""Import via excel des notes de toutes les évals d'un semestre"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
dest_url = url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)
if not formsemestre.est_chef_or_diretud():
raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
class ImportForm(FlaskForm):
evaluation_id = HiddenField("formsemestre_id", default=formsemestre.id)
notefile = FileField(
"Fichier d'import",
validators=[
DataRequired(),
FileAllowed(["xlsx"], "Fichier xlsx seulement !"),
],
)
comment = StringField("Commentaire", validators=[Length(max=256)])
submit = SubmitField("Télécharger")
form = ImportForm()
if form.validate_on_submit():
# 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 render_template(
"formsemestre/import_notes.j2",
evaluations=formsemestre.get_evaluations(),
form=form,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
)
@bp.route("/feuille_import_notes/<int:formsemestre_id>")
@scodoc
@permission_required(Permission.ScoView)
def feuille_import_notes(formsemestre_id: int):
"""Feuille excel pour importer les notes de toutes les évaluations du semestre"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
xls = sco_saisie_excel.excel_feuille_import(formsemestre)
filename = scu.sanitize_filename(formsemestre.titre_annee())
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
# --- Bulletins
@bp.route("/formsemestre_bulletins_pdf")
@scodoc

View File

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