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>""" }">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>") H.append("</div>")
return "\n".join(H) return "\n".join(H)
@ -1134,20 +1159,6 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
"</div>", "</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: # --- Lien mail enseignants:
adrlist = list(mails_enseignants - {None, ""}) adrlist = list(mails_enseignants - {None, ""})
if adrlist: 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; 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> </style>
@ -657,6 +677,8 @@
matin.textContent = `${temps.matin.debut} à ${temps.matin.fin}`; matin.textContent = `${temps.matin.debut} à ${temps.matin.fin}`;
apresmidi.textContent = `${temps.apresmidi.debut} à ${temps.apresmidi.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); recupAssiduitesHebdo(updateTable);
} }
@ -685,6 +707,14 @@
updateTemps(temps); 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>
<script> <script>
@ -782,9 +812,59 @@ document.addEventListener("DOMContentLoaded", ()=>{
{{moduleimpl_select | safe}} {{moduleimpl_select | safe}}
</label> </label>
<button onclick="changeWeek(false)">Semaine suivante</button> <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> </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"> <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> 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> </h3>

View File

@ -31,7 +31,7 @@ import re
from collections import OrderedDict from collections import OrderedDict
from flask import g, request, render_template, flash 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_login import current_user
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
@ -39,6 +39,7 @@ from markupsafe import Markup
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from app import db, log from app import db, log
from app.api import tools
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.decorators import ( from app.decorators import (
@ -59,6 +60,7 @@ from app.models import (
GroupDescr, GroupDescr,
Identite, Identite,
Justificatif, Justificatif,
Module,
ModuleImpl, ModuleImpl,
ScoDocSiteConfig, ScoDocSiteConfig,
Scolog, Scolog,
@ -2007,6 +2009,8 @@ def signal_assiduites_hebdo():
moduleimpl_id=moduleimpl_id, moduleimpl_id=moduleimpl_id,
) )
erreurs: list = session.pop("feuille_abs_hebdo-erreurs", [])
return render_template( return render_template(
"assiduites/pages/signal_assiduites_hebdo.j2", "assiduites/pages/signal_assiduites_hebdo.j2",
title="Assiduité: saisie hebdomadaire", title="Assiduité: saisie hebdomadaire",
@ -2023,6 +2027,8 @@ def signal_assiduites_hebdo():
dept_id=g.scodoc_dept_id, dept_id=g.scodoc_dept_id,
), ),
url_choix_semaine=url_choix_semaine, 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(): def feuille_abs_hebdo():
""" """
GET : Renvoie un tableau excel pour permettre la saisie des absences 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é 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 # Récupération des groupes
@ -2326,8 +2335,35 @@ def feuille_abs_hebdo():
hebdo_jours.append((key in non_travail, val)) hebdo_jours.append((key in non_travail, val))
if request.method == "POST": if request.method == "POST":
flash("Les absences ont bien été enregistrées") url_saisie: str = url_for(
return redirect(request.referrer) "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}" filename = f"feuille_signal_abs_{week}"
xls = _excel_feuille_abs( 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) 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 --- # --- 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( def _excel_feuille_abs(
formsemestre: FormSemestre, formsemestre: FormSemestre,
groups_infos: sco_groups_view.DisplayedGroupsInfos, groups_infos: sco_groups_view.DisplayedGroupsInfos,
@ -2446,7 +2753,7 @@ def _excel_feuille_abs(
# == Ecritures statiques == # == Ecritures statiques ==
ws.append_single_cell_row( 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) ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl)
# Nom du semestre # Nom du semestre