Assiduité : import excels closes #926

This commit is contained in:
Iziram 2024-07-04 16:46:07 +02:00
parent abb460e659
commit 89c5307472
4 changed files with 532 additions and 20 deletions

View File

@ -910,6 +910,31 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
}">Ajouter une partition</a></h4>"""
)
# --- Formulaire importation Assiduité excel (si autorisé)
if current_user.has_permission(Permission.AbsChange):
H.append(
f"""<p>
<a class="stdlink" href="{url_for('assiduites.feuille_abs_formsemestre',
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)}">
Importation de l'assiduité depuis un fichier excel</a>
</p>"""
)
# --- Lien Traitement Justificatifs:
if current_user.has_permission(
Permission.AbsJustifView
) and current_user.has_permission(Permission.JustifValidate):
H.append(
f"""<p>
<a class="stdlink" href="{url_for('assiduites.traitement_justificatifs',
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)}">
Traitement des justificatifs d'absence</a>
</p>"""
)
H.append("</div>")
return "\n".join(H)
@ -1134,20 +1159,6 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
"</div>",
]
# --- Lien Traitement Justificatifs:
if current_user.has_permission(
Permission.AbsJustifView
) and current_user.has_permission(Permission.JustifValidate):
H.append(
f"""<p>
<a class="stdlink" href="{url_for('assiduites.traitement_justificatifs',
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id)}">
Traitement des justificatifs d'absence</a>
</p>"""
)
# --- Lien mail enseignants:
adrlist = list(mails_enseignants - {None, ""})
if adrlist:

View File

@ -0,0 +1,114 @@
{% extends "sco_page.j2" %}
{% block styles %}
{{super()}}
<style>
#excel-content {
margin: 4px 0;
display: flex;
flex-direction: column;
gap: 4px;
justify-content: space-evenly;
}
.hint {
font-style: italic;
}
#excel-errors {
background-color: rgb(241, 209, 209);
}
.stdlink {
width: fit-content;
}
.star::after {
content: "*";
color: red;
}
.opt::after {
content: "*";
color: blue;
}
pre {
width: fit-content;
}
</style>
{% endblock styles %}
{% block app_content %}
<h1>Importation de l'assiduité depuis un fichier excel</h1>
<h2 style="color: crimson;">{{titre_form}}</h2>
<div class="scobox">
<div id="excel-content">
<p class="hint">Avertissement : le fichier doit respecter le format suivant</p>
<ul>
<li>
colonne A : Identifiant de l'étudiant
</li>
<li class="star">colonne B : Date de début</li>
<li class="star">colonne C : Date de fin</li>
<li class="opt">colonne D : État (ABS,RET,PRE, ABS si vide)</li>
<li class="opt">colonne E : code du module</li>
</ul>
<p class="hint"><span class="opt"></span> : Colonne optionnelle, les cases peuvent être vides</p>
<p class="hint"><span class="star"></span> : Formats autorisés :
<ul>
<li>
<pre>aaaa-mm-jjThh:mm:ss</pre>
</li>
<li>
<pre>jj/mm/aaaa hh:mm:ss</pre>
</li>
</ul>
</p>
<p class="hint">La première ligne du fichier <strong>ne doit pas</strong> être une ligne d'entête</p>
<form action="" method="post" enctype="multipart/form-data">
<label for="type_identifiant">Type d'identifiant d'étudiant (etudid, ine, nip)</label>
<select name="type_identifiant" id="type_identifiant">
<option value="etudid">ETUDID</option>
<option value="ine">INE</option>
<option value="nip">NIP</option>
</select>
<br>
<label for="file">
Sélectionnez un fichier:
</label>
<input id="file" type="file" name="file"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel" />
<br>
<button type="submit">Importer</button>
</form>
</div>
</div>
{% if erreurs %}
<div class="scobox erreurs" id="excel-errors">
<div>
<p class="hint">Les erreurs suivantes ont été trouvées dans le fichier excel :</p>
<ul>
{% for erreur in erreurs %}
<li>
<span>
{{erreur[0]}}
&rightarrow;
Ligne : {{erreur[1][0]}}
</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% endblock app_content %}

View File

@ -113,6 +113,26 @@
cursor: pointer;
}
#excel-content {
margin: 4px 0;
display: flex;
flex-direction: column;
gap: 4px;
justify-content: space-evenly;
}
.hint {
font-style: italic;
}
#excel-errors {
background-color: rgb(241, 209, 209);
}
.stdlink{
width: fit-content;
}
</style>
@ -657,6 +677,8 @@
matin.textContent = `${temps.matin.debut} à ${temps.matin.fin}`;
apresmidi.textContent = `${temps.apresmidi.debut} à ${temps.apresmidi.fin}`;
document.getElementById("excel-heures").value = Object.values(temps).map((el)=>Object.values(el).join(",")).join(",");
recupAssiduitesHebdo(updateTable);
}
@ -685,6 +707,14 @@
updateTemps(temps);
const select = document.getElementById("moduleimpl_select");
const excelModule = document.getElementById("excel-module");
select.addEventListener("change", (ev)=>{
excelModule.value = ev.target.value;
});
excelModule.value = select.value;
</script>
<script>
@ -782,9 +812,59 @@ document.addEventListener("DOMContentLoaded", ()=>{
{{moduleimpl_select | safe}}
</label>
<button onclick="changeWeek(false)">Semaine suivante</button>
<span><a href="{{url_choix_semaine}}" class="stdlink">autre semaine<a></span>
<span><a href="{{url_choix_semaine}}" class="stdlink">autre semaine</a></span>
</div>
{% if readonly %}
{% else %}
<div class="scobox">
<details>
<summary>
<span>Importation à partir d'un fichier excel</span>
</summary>
<div id="excel-content">
<a href="feuille_abs_hebdo?{{query_string|safe}}" class="stdlink">Télécharger le modèle</a>
<p class="hint">Avertissement : l'assiduité sera enregistrée en prenant en compte le module sélectionné plus haut et les heures sélectionnées plus bas.</p>
<form action="feuille_abs_hebdo?{{query_string|safe}}" method="post" enctype="multipart/form-data">
<input type="hidden" name="heures" value="" id="excel-heures">
<input type="hidden" name="moduleimpl_id" value="" id="excel-module">
<label for="file">
Sélectionnez un fichier:
<input id="file" type="file" name="file" accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel" />
</label>
<button type="submit">Importer</button>
</form>
</div>
</details>
</div>
{% endif %}
{% if erreurs %}
<div class="scobox erreurs" id="excel-errors">
<details>
<summary>Erreurs</summary>
<div>
<p class="hint">Les erreurs suivantes ont été trouvées dans le fichier excel :</p>
<ul>
{% for erreur in erreurs %}
<li>
<span>
{{erreur[0]}}
&rightarrow;
Colonne : {{erreur[1][1]}} - Ligne : {{erreur[1][0]}}
</span>
</li>
{% endfor %}
</ul>
</div>
</details>
</div>
{% endif %}
<h3 id="tableau-dates">
Le matin <a href="#" id="text-matin" title="Cliquer pour modifier les horaires">9h à 12h</a> et l'après-midi de <a href="#" id="text-apresmidi" title="Cliquer pour modifier les horaires">13h à 17h</a>
</h3>

View File

@ -31,7 +31,7 @@ import re
from collections import OrderedDict
from flask import g, request, render_template, flash
from flask import abort, url_for, redirect, Response
from flask import abort, url_for, redirect, Response, session
from flask_login import current_user
from flask_sqlalchemy.query import Query
@ -39,6 +39,7 @@ from markupsafe import Markup
from werkzeug.exceptions import HTTPException
from app import db, log
from app.api import tools
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
@ -59,6 +60,7 @@ from app.models import (
GroupDescr,
Identite,
Justificatif,
Module,
ModuleImpl,
ScoDocSiteConfig,
Scolog,
@ -2007,6 +2009,8 @@ def signal_assiduites_hebdo():
moduleimpl_id=moduleimpl_id,
)
erreurs: list = session.pop("feuille_abs_hebdo-erreurs", [])
return render_template(
"assiduites/pages/signal_assiduites_hebdo.j2",
title="Assiduité: saisie hebdomadaire",
@ -2023,6 +2027,8 @@ def signal_assiduites_hebdo():
dept_id=g.scodoc_dept_id,
),
url_choix_semaine=url_choix_semaine,
query_string=request.query_string.decode(encoding="utf-8"),
erreurs=erreurs,
)
@ -2225,8 +2231,11 @@ def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
def feuille_abs_hebdo():
"""
GET : Renvoie un tableau excel pour permettre la saisie des absences
POST: Enregistre les absences saisies
POST: Enregistre les absences saisies et renvoie sur la page de saisie hebdomadaire
Affiche un choix de semaine si "week" n'est pas renseigné
Si POST:
renvoie sur la page saisie_assiduites_hebdo
"""
# Récupération des groupes
@ -2326,8 +2335,35 @@ def feuille_abs_hebdo():
hebdo_jours.append((key in non_travail, val))
if request.method == "POST":
flash("Les absences ont bien été enregistrées")
return redirect(request.referrer)
url_saisie: str = url_for(
"assiduites.signal_assiduites_hebdo",
scodoc_dept=g.scodoc_dept,
**request.args,
)
# Vérification du fichier
file = request.files.get("file", None)
if file is None or file.filename == "":
flash("Erreur : Pas de fichier")
return redirect(url_saisie)
# Vérification des heures
heures: list[str] = request.form.get("heures", "").split(",")
if len(heures) != 4:
flash("Erreur : Les heures sont incorrectes")
return redirect(url_saisie)
# Récupération du moduleimpl
moduleimpl_id = request.form.get("moduleimpl_id")
# Enregistrement des assiduites
erreurs = _import_feuille_abs_hebdo(
file,
heures=["08:15", "13:00", "13:00", "18:15"],
moduleimpl_id=moduleimpl_id,
)
if erreurs:
session["feuille_abs_hebdo-erreurs"] = erreurs
return redirect(url_saisie)
filename = f"feuille_signal_abs_{week}"
xls = _excel_feuille_abs(
@ -2336,9 +2372,280 @@ def feuille_abs_hebdo():
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
@bp.route("feuille_abs_formsemestre", methods=["GET", "POST"])
@scodoc
def feuille_abs_formsemestre():
"""
Permet l'importation d'une liste d'assiduités depuis un fichier excel
GET:
Affiche un formulaire pour l'import et les erreurs lors de l'import
POST:
Nécessite un fichier excel contenant une liste d'assiduités (file)
"""
# Récupération du formsemestre
formsemestre_id: int = request.args.get("formsemestre_id", -1)
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
erreurs: list = []
if request.method == "POST":
# Récupération et vérification du fichier
file = request.files.get("file")
if file is None or file.filename == "":
raise ScoValueError("Pas de fichier", dest_url=request.referrer)
# Récupération du type d'identifiant
type_identifiant: str = request.form.get("type_identifiant", "etudid")
# Importation du fichier
erreurs = _import_excel_assiduites_list(
file, formsemestre=formsemestre, type_etud_identifiant=type_identifiant
)
if erreurs:
flash("Erreurs lors de l'importation, voir bas de page", "error")
else:
flash("Importation réussie")
return render_template(
"assiduites/pages/feuille_abs_formsemestre.j2",
erreurs=erreurs,
titre_form=formsemestre.titre_annee(),
sco=ScoData(formsemestre=formsemestre),
)
# --- Fonctions internes ---
def _import_feuille_abs_hebdo(
file, heures: list[str], moduleimpl_id: int = None
) -> list:
"""
Importe un fichier excel au format de la feuille d'absence hebdomadaire
(voir _excel_feuille_abs)
Génère les assiduités correspondantes et retourne une liste d'erreurs
Les erreurs sont sous la forme :
(message, [num_ligne, ...contenu_ligne])
Attention : num_ligne correspond au numéro de la ligne dans le fichier excel
"""
data: list = sco_excel.excel_file_to_list(file)
erreurs: list = []
# Récupération des jours (entête du tableau)
jours: list[str] = [
str_jour.split(" ")[1] for str_jour in data[1][4][4:] if str_jour
]
lettres: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# On récupère uniquement les lignes d'étudiants (on ignore les headers)
data: list = data[1][6:]
# Chaque ligne commence par [!etudid][nom][prenom][groupe]
for num, ligne in enumerate(data):
etudid = ligne[0].replace("!", "") # on enlève le point d'exclamation
try:
etud = Identite.get_etud(etudid)
except HTTPException as exc:
erreurs.append((exc.description, [num + 6, "A"] + ligne))
continue
for i, etat in enumerate(ligne[4:]):
try:
etat: str = etat.strip().upper()
if etat:
# Vérification de l'état
if etat not in ["ABS", "RET", "PRE"]:
raise ScoValueError(f"État invalide => {etat}")
etat: scu.EtatAssiduite = {
"ABS": scu.EtatAssiduite.ABSENT,
"RET": scu.EtatAssiduite.RETARD,
"PRE": scu.EtatAssiduite.PRESENT,
}.get(etat, scu.EtatAssiduite.ABSENT)
else:
continue
# Génération des dates de début et de fin de l'assiduité
heure_debut: str = heures[0] if i % 2 == 0 else heures[2]
heure_fin: str = heures[1] if i % 2 == 0 else heures[3]
try:
date_debut: datetime.datetime = datetime.datetime.strptime(
jours[i // 2] + " " + heure_debut, "%d/%m/%Y %H:%M"
)
date_fin: datetime.datetime = datetime.datetime.strptime(
jours[i // 2] + " " + heure_fin, "%d/%m/%Y %H:%M"
)
except ValueError as exc:
raise ScoValueError("Dates Invalides") from exc
# On met les dates à la timezone du serveur
date_debut = scu.TIME_ZONE.localize(date_debut)
date_fin = scu.TIME_ZONE.localize(date_fin)
# Création de l'assiduité
assiduite: Assiduite = Assiduite.create_assiduite(
etud=etud,
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
user_id=current_user.id,
)
if moduleimpl_id:
assiduite.set_moduleimpl(moduleimpl_id)
db.session.add(assiduite)
scass.simple_invalidate_cache(assiduite.to_dict())
except HTTPException as exc:
erreurs.append((exc.description, [num + 6, lettres[i + 4]] + ligne))
except ScoValueError as exc:
erreurs.append((exc.args[0], [num + 6, lettres[i + 4]] + ligne))
continue
# On commit les changements
db.session.commit()
return erreurs
def _import_excel_assiduites_list(
file, formsemestre: FormSemestre, type_etud_identifiant: str = "etudid"
) -> list:
"""
Importe un fichier excel contenant une liste d'assiduités
sous le format :
| etudid/nip/ine | date_debut | date_fin | etat [optionnel -> "ABS"] | Module [optionnel -> None] |
Génère les assiduités correspondantes et retourne une liste d'erreurs
Les erreurs sont sous la forme :
(message, [num_ligne, ...contenu_ligne])
Attention : num_ligne correspond au numéro de la ligne dans le fichier excel
"""
# On récupère les données du fichier
data: list = sco_excel.excel_file_to_list(file)
# On récupère le deuxième élément de la liste (les lignes du tableur)
# Le premier element de la liste correspond à la description des feuilles excel
data: list = data[1]
# On parcourt les lignes et on les traite
erreurs: list[tuple[str, list]] = []
for num, ligne in enumerate(data):
identifiant_etud = ligne[0] # etudid/nip/ine
date_debut_str = ligne[1] # iso / fra / excel
date_fin_str = ligne[2] # iso / fra / excel
etat = ligne[3].strip().upper() # etat abs par défaut, sinon RET ou PRE
etat = etat or "ABS"
module = ligne[4] or None # code du module
moduleimpl: ModuleImpl | None = None
try:
# On récupère l'étudiant
etud: Identite = _find_etud(identifiant_etud, type_etud_identifiant)
# On vérifie que l'étudiant appartient au semestre
if formsemestre not in etud.get_formsemestres():
raise ScoValueError("Étudiant non inscrit dans le semestre")
# On transforme les dates
date_debut: datetime.datetime = _try_parse_date(date_debut_str)
date_fin: datetime.datetime = _try_parse_date(date_fin_str)
# On met les dates à la timezone du serveur
date_debut = scu.TIME_ZONE.localize(date_debut)
date_fin = scu.TIME_ZONE.localize(date_fin)
# Vérification de l'état
if etat not in ["ABS", "RET", "PRE"]:
raise ScoValueError(f"État invalide => {etat}")
etat: scu.EtatAssiduite = {
"ABS": scu.EtatAssiduite.ABSENT,
"RET": scu.EtatAssiduite.RETARD,
"PRE": scu.EtatAssiduite.PRESENT,
}.get(etat, scu.EtatAssiduite.ABSENT)
# On récupère le moduleimpl à partir du code du module et du formsemestre
if module:
moduleimpl = _get_moduleimpl_from_code(module, formsemestre)
assiduite: Assiduite = Assiduite.create_assiduite(
etud=etud,
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
moduleimpl=moduleimpl,
)
db.session.add(assiduite)
scass.simple_invalidate_cache(assiduite.to_dict())
except ScoValueError as exc:
erreurs.append((exc.args[0], [num + 1] + ligne))
except HTTPException as exc:
erreurs.append((exc.description, [num + 1] + ligne))
db.session.commit()
return erreurs
def _get_moduleimpl_from_code(
module_code: str, formsemestre: FormSemestre
) -> ModuleImpl:
query: Query = ModuleImpl.query.filter(
ModuleImpl.module.has(Module.code == module_code),
ModuleImpl.formsemestre_id == formsemestre.id,
)
moduleimpl: ModuleImpl = query.first()
if moduleimpl is None:
raise ScoValueError("Module non trouvé")
return moduleimpl
def _try_parse_date(date: str) -> datetime.datetime:
"""
Tente de parser une date sous différents formats
renvoie la première date valide
"""
# On tente de parser la date en iso (yyyy-mm-ddThh:mm:ss)
try:
return datetime.datetime.fromisoformat(date)
except ValueError:
pass
# On tente de parser la date en français (dd/mm/yyyy hh:mm:ss)
try:
return datetime.datetime.strptime(date, "%d/%m/%Y %H:%M:%S")
except ValueError:
pass
raise ScoValueError("Date invalide")
def _find_etud(identifiant: str, type_identifiant: str) -> Identite:
"""
Renvoie l'étudiant correspondant à l'identifiant
"""
if type_identifiant == "etudid":
return tools.get_etud(etudid=identifiant)
elif type_identifiant == "nip":
return tools.get_etud(nip=identifiant)
elif type_identifiant == "ine":
return tools.get_etud(ine=identifiant)
else:
raise ScoValueError("Type d'identifiant invalide")
def _excel_feuille_abs(
formsemestre: FormSemestre,
groups_infos: sco_groups_view.DisplayedGroupsInfos,
@ -2446,7 +2753,7 @@ def _excel_feuille_abs(
# == Ecritures statiques ==
ws.append_single_cell_row(
"Saisir les assiduités dans les cases jaunes", style=style_expl
"Saisir les assiduités dans les cases jaunes (ABS, RET, PRE)", style=style_expl
)
ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl)
# Nom du semestre