ScoDoc/app/views/assiduites.py

2618 lines
88 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
2023-12-31 23:04:06 +01:00
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
# module codé par Matthias Hartmann, 2023
#
##############################################################################
2023-04-17 15:44:55 +02:00
import datetime
2024-02-27 08:58:09 +01:00
import json
2023-11-15 14:11:47 +01:00
import re
2023-04-17 15:44:55 +02:00
from collections import OrderedDict
from flask import g, request, render_template, flash
from flask import abort, url_for, redirect, Response
from flask_login import current_user
from flask_sqlalchemy.query import Query
2023-04-17 15:44:55 +02:00
2023-12-12 03:05:31 +01:00
from app import db, log
2023-04-17 15:44:55 +02:00
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
scodoc,
permission_required,
)
2023-12-12 03:05:31 +01:00
from app.forms.assiduite.ajout_assiduite_etud import (
AjoutAssiOrJustForm,
AjoutAssiduiteEtudForm,
AjoutJustificatifEtudForm,
ChoixDateForm,
2023-12-12 03:05:31 +01:00
)
from app.models import (
Assiduite,
Departement,
Evaluation,
FormSemestre,
2023-12-09 15:53:45 +01:00
GroupDescr,
Identite,
Justificatif,
ModuleImpl,
ScoDocSiteConfig,
)
2023-12-05 21:04:38 +01:00
from app.scodoc.codes_cursus import UE_STANDARD
from app.auth.models import User
2023-12-12 03:05:31 +01:00
from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified
2023-12-09 15:53:45 +01:00
from app.tables.list_etuds import RowEtud, TableEtud
import app.tables.liste_assiduites as liste_assi
2023-04-17 15:44:55 +02:00
from app.views import assiduites_bp as bp
from app.views import ScoData
# ---------------
from app.scodoc.sco_permissions import Permission
from app.scodoc import html_sco_header
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_groups_view
from app.scodoc import sco_etud
from app.scodoc import sco_find_etud
2023-05-30 17:16:07 +02:00
from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
2023-04-17 15:44:55 +02:00
from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
2023-04-17 15:44:55 +02:00
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
# --------------------------------------------------------------------
#
2023-09-21 08:46:21 +02:00
# Assiduité (/ScoDoc/<dept>/Scolarite/Assiduites/...)
2023-04-17 15:44:55 +02:00
#
# --------------------------------------------------------------------
@bp.route("/")
@bp.route("/bilan_dept")
2023-04-17 15:44:55 +02:00
@scodoc
@permission_required(Permission.AbsChange)
def bilan_dept():
2023-04-17 15:44:55 +02:00
"""Gestionnaire assiduités, page principale"""
2023-10-27 16:05:40 +02:00
# Gestion des billets d'absences
if current_user.has_permission(
Permission.AbsChange
) and sco_preferences.get_preference("handle_billets_abs"):
2024-01-19 17:06:01 +01:00
billets = f"""
<h2 style="margin-top: 30px;">Billets d'absence</h2>
<ul><li><a href="{url_for("absences.list_billets", scodoc_dept=g.scodoc_dept)
}">Traitement des billets d'absence en attente</a>
</li></ul>
"""
2024-01-19 17:06:01 +01:00
else:
billets = ""
# Récupération du département
2023-09-12 09:41:50 +02:00
dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first()
2023-06-28 17:15:24 +02:00
2023-10-27 16:05:40 +02:00
# Récupération d'un formsemestre
2024-01-19 17:06:01 +01:00
# (pour n'afficher que les justificatifs liés au formsemestre)
formsemestre_id = request.args.get("formsemestre_id", "")
2024-01-19 17:06:01 +01:00
formsemestre = None
if formsemestre_id:
try:
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
except AttributeError:
formsemestre_id = ""
2024-01-19 17:06:01 +01:00
# <=> Génération du tableau <=>
# Récupération des étudiants du département / groupe
etudids: list[int] = [etud.id for etud in dept.etudiants] # cas département
group_ids = request.args.get("group_ids", "")
if group_ids and formsemestre:
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids.split(","),
formsemestre_id=formsemestre.id,
select_all_when_unspecified=True,
)
if groups_infos.members:
etudids = [m["etudid"] for m in groups_infos.members]
# justificatifs (en attente ou modifiés avec les semestres associés)
justificatifs_query: Query = Justificatif.query.filter(
Justificatif.etat.in_(
[scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE]
2023-06-28 17:15:24 +02:00
),
2024-01-19 17:06:01 +01:00
Justificatif.etudid.in_(etudids),
)
# Filtrage par semestre si formsemestre_id != ""
if formsemestre:
justificatifs_query = justificatifs_query.filter(
Justificatif.date_debut >= formsemestre.date_debut,
Justificatif.date_debut <= formsemestre.date_fin,
)
data = liste_assi.AssiJustifData(
assiduites_query=None,
justificatifs_query=justificatifs_query,
)
fname: str = "Bilan Département"
cache_key: str = "tableau-dept"
titre: str = "Justificatifs en attente ou modifiés"
if formsemestre:
fname += f" {formsemestre.titre_annee()}"
cache_key += f"-{formsemestre.id}"
titre += f" {formsemestre.titre_annee()}"
if group_ids:
cache_key += f" {group_ids}"
table = _prepare_tableau(
data,
afficher_etu=True,
filename=fname,
titre=titre,
cache_key=cache_key,
)
if not table[0]:
return table[1]
# Peuplement du template jinja
return render_template(
"assiduites/pages/bilan_dept.j2",
tableau=table[1],
search_etud=sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"),
billets=billets,
sco=ScoData(formsemestre=formsemestre),
2023-06-28 17:15:24 +02:00
)
2023-04-17 15:44:55 +02:00
2023-12-07 12:38:47 +01:00
@bp.route("/ajout_assiduite_etud", methods=["GET", "POST"])
2023-04-17 15:44:55 +02:00
@scodoc
@permission_required(Permission.AbsChange)
def ajout_assiduite_etud() -> str | Response:
2023-04-17 15:44:55 +02:00
"""
2023-12-05 21:04:38 +01:00
ajout_assiduite_etud Saisie d'une assiduité d'un étudiant
2023-04-17 15:44:55 +02:00
Args:
etudid (int): l'identifiant de l'étudiant
date_deb, date_fin: heures début et fin (ISO sans timezone)
moduleimpl_id
2023-12-05 21:04:38 +01:00
evaluation_id : si présent, mode "évaluation"
fmt: si xls, renvoie le tableau des assiduités enregistrées
2023-04-17 15:44:55 +02:00
Returns:
str: l'html généré
"""
etudid: int = request.args.get("etudid", -1)
etud = Identite.get_etud(etudid)
# Gestion évaluations (appel à la page depuis les évaluations)
evaluation_id: int | None = request.args.get("evaluation_id")
2023-12-05 21:04:38 +01:00
saisie_eval = evaluation_id is not None
moduleimpl_id: int | None = request.args.get("moduleimpl_id", "")
2023-12-05 21:04:38 +01:00
redirect_url: str = (
"#"
if not saisie_eval
else url_for(
"notes.evaluation_check_absences_html",
evaluation_id=evaluation_id,
scodoc_dept=g.scodoc_dept,
)
)
2023-12-05 21:04:38 +01:00
form = AjoutAssiduiteEtudForm(request.form)
# On dresse la liste des modules de l'année scolaire en cours
# auxquels est inscrit l'étudiant pour peupler le menu "module"
modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
choices: OrderedDict = OrderedDict()
choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
2023-12-05 21:04:38 +01:00
for formsemestre_id in modimpls_by_formsemestre:
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
2023-12-05 21:04:38 +01:00
# indique le nom du semestre dans le menu (optgroup)
group_name: str = formsemestre.titre_annee()
choices[group_name] = [
2023-12-07 16:11:21 +01:00
(m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
2023-12-05 21:04:38 +01:00
for m in modimpls_by_formsemestre[formsemestre_id]
if m.module.ue.type == UE_STANDARD
]
if formsemestre.est_courant():
choices.move_to_end(group_name, last=False)
choices.move_to_end("", last=False)
2023-12-05 21:04:38 +01:00
form.modimpl.choices = choices
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(redirect_url)
ok = _record_assiduite_etud(etud, form)
if ok:
flash("enregistré")
return redirect(redirect_url)
2023-04-17 15:44:55 +02:00
2023-12-05 21:04:38 +01:00
# Le tableau des assiduités+justificatifs déjà en base:
is_html, tableau = _prepare_tableau(
liste_assi.AssiJustifData.from_etudiants(
2023-11-24 18:07:30 +01:00
etud,
),
2023-12-12 03:05:31 +01:00
filename=f"assiduite-{etud.nom or ''}",
afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=1),
options=liste_assi.AssiDisplayOptions(show_module=True),
cache_key=f"tableau-etud-{etud.id}",
)
if not is_html:
return tableau
2023-12-05 21:04:38 +01:00
return render_template(
"assiduites/pages/ajout_assiduite_etud.j2",
etud=etud,
form=form,
moduleimpl_id=moduleimpl_id,
redirect_url=redirect_url,
sco=ScoData(etud),
tableau=tableau,
scu=scu,
)
2023-12-12 03:05:31 +01:00
def _get_dates_from_assi_form(
form: AjoutAssiOrJustForm,
2024-02-26 18:26:04 +01:00
all_day: bool = False,
) -> tuple[
bool, datetime.datetime | None, datetime.datetime | None, datetime.datetime | None
]:
2023-12-12 03:05:31 +01:00
"""Prend les dates et heures du form, les vérifie
puis converti en deux datetime, en timezone du serveur.
Ramène ok=True si ok.
Met des messages d'erreur dans le form.
2023-12-05 21:04:38 +01:00
"""
debut_jour = ScoDocSiteConfig.get("assi_morning_time", "08:00")
fin_jour = ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
2023-12-07 12:38:47 +01:00
date_fin = None
2023-12-05 21:04:38 +01:00
# On commence par convertir individuellement tous les champs
try:
date_debut = datetime.datetime.strptime(form.date_debut.data, "%d/%m/%Y")
except ValueError:
date_debut = None
form.set_error("date début invalide", form.date_debut)
2023-12-05 21:04:38 +01:00
try:
date_fin = (
datetime.datetime.strptime(form.date_fin.data, "%d/%m/%Y")
if form.date_fin.data
else None
)
except ValueError:
date_fin = None
form.set_error("date fin invalide", form.date_fin)
2023-12-05 21:04:38 +01:00
if date_fin:
# ignore les heures si plusieurs jours
2024-02-26 18:26:04 +01:00
# Assiduité : garde les heures inscritent dans le formulaire
# Justificatif : ignore les heures inscrites dans le formulaire (0h -> 23h59)
heure_debut = (
datetime.time.fromisoformat(debut_jour)
if not all_day
else datetime.time(0, 0, 0)
) # 0h ou ConfigAssiduite.MorningTime
heure_fin = (
datetime.time.fromisoformat(fin_jour)
if not all_day
else datetime.time(23, 59, 59)
) # 23h59 ou ConfigAssiduite.AfternoonTime
2023-12-05 21:04:38 +01:00
else:
try:
2024-02-26 18:26:04 +01:00
heure_debut = datetime.time.fromisoformat(form.heure_debut.data or "00:00")
2023-12-05 21:04:38 +01:00
except ValueError:
form.set_error("heure début invalide", form.heure_debut)
if bool(form.heure_debut.data) != bool(form.heure_fin.data):
form.set_error(
"Les deux heures début et fin doivent être spécifiées, ou aucune"
)
2023-12-05 21:04:38 +01:00
try:
2024-02-26 18:26:04 +01:00
heure_fin = datetime.time.fromisoformat(form.heure_fin.data or "23:59")
2023-12-05 21:04:38 +01:00
except ValueError:
form.set_error("heure fin invalide", form.heure_fin)
if not form.ok:
return False, None, None, None
2023-12-12 03:05:31 +01:00
# Vérifie cohérence des dates/heures
dt_debut = datetime.datetime.combine(date_debut, heure_debut)
dt_fin = datetime.datetime.combine(date_fin or date_debut, heure_fin)
if dt_fin <= dt_debut:
form.set_error("dates début/fin incohérentes")
# La date de dépôt (si vide, la date actuelle)
try:
dt_entry_date = (
datetime.datetime.strptime(form.entry_date.data, "%d/%m/%Y")
if form.entry_date.data
else datetime.datetime.now() # local tz
)
except ValueError:
dt_entry_date = None
form.set_error("format de date de dépôt invalide", form.entry_date)
# L'heure de dépôt
try:
entry_time = datetime.time.fromisoformat(
form.entry_time.data or datetime.datetime.now().time().isoformat("seconds")
)
except ValueError:
dt_entry_date = None
form.set_error("format d'heure de dépôt invalide", form.entry_date)
if dt_entry_date:
dt_entry_date = datetime.datetime.combine(dt_entry_date, entry_time)
2023-12-12 03:05:31 +01:00
# Ajoute time zone serveur
dt_debut_tz_server = scu.TIME_ZONE.localize(dt_debut)
dt_fin_tz_server = scu.TIME_ZONE.localize(dt_fin)
2023-12-12 03:05:31 +01:00
dt_entry_date_tz_server = (
scu.TIME_ZONE.localize(dt_entry_date) if dt_entry_date else None
)
return form.ok, dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server
2023-12-12 03:05:31 +01:00
def _record_assiduite_etud(
etud: Identite,
form: AjoutAssiduiteEtudForm,
) -> bool:
"""Enregistre les données du formulaire de saisie assiduité.
Returns ok if successfully recorded, else put error info in the form.
Format attendu des données du formulaire:
form.assi_etat.data : 'absent'
form.date_debut.data : '05/12/2023'
form.heure_debut.data : '09:06' (heure locale du serveur)
"""
(
ok,
dt_debut_tz_server,
dt_fin_tz_server,
dt_entry_date_tz_server,
) = _get_dates_from_assi_form(form)
2023-12-05 21:04:38 +01:00
# Le module (avec "autre")
mod_data = form.modimpl.data
if mod_data:
if mod_data == "autre":
moduleimpl_id = "autre"
else:
try:
moduleimpl_id = int(mod_data)
except ValueError:
form.modimpl.error("choix de module invalide")
ok = False
else:
moduleimpl_id = None
2023-12-12 03:05:31 +01:00
2023-12-05 21:04:38 +01:00
if not ok:
return False
2023-12-12 03:05:31 +01:00
external_data = None
moduleimpl: ModuleImpl | None = None
match moduleimpl_id:
case "autre":
external_data = {"module": "Autre"}
case None:
moduleimpl = None
case _:
moduleimpl = ModuleImpl.query.get(moduleimpl_id)
try:
assi_etat: scu.EtatAssiduite = scu.EtatAssiduite.get(form.assi_etat.data)
ass = Assiduite.create_assiduite(
etud,
dt_debut_tz_server,
dt_fin_tz_server,
assi_etat,
2023-12-22 15:59:24 +01:00
description=form.description.data,
entry_date=dt_entry_date_tz_server,
external_data=external_data,
moduleimpl=moduleimpl,
notify_mail=True,
user_id=current_user.id,
)
db.session.add(ass)
db.session.commit()
if assi_etat != scu.EtatAssiduite.PRESENT and form.est_just.data:
# si la case "justifiée est cochée alors on créé un justificatif de même période"
justi: Justificatif = Justificatif.create_justificatif(
etudiant=etud,
date_debut=dt_debut_tz_server,
date_fin=dt_fin_tz_server,
etat=scu.EtatJustificatif.VALIDE,
user_id=current_user.id,
)
# On met à jour les assiduités en fonction du nouveau justificatif
compute_assiduites_justified(etud.id, [justi])
# Invalider cache
scass.simple_invalidate_cache(ass.to_dict(), etud.id)
2023-12-05 21:04:38 +01:00
return True
except ScoValueError as exc:
form.set_error(f"Erreur: {exc.args[0]}")
return False
2023-12-05 21:04:38 +01:00
2023-04-17 15:44:55 +02:00
2023-12-09 15:53:45 +01:00
@bp.route("/liste_assiduites_etud")
@scodoc
2023-06-30 17:24:16 +02:00
@permission_required(Permission.ScoView)
def liste_assiduites_etud():
"""
liste_assiduites_etud Affichage de toutes les assiduites et justificatifs d'un etudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
2023-10-27 16:05:40 +02:00
# Récupération de l'étudiant concerné
etudid = request.args.get("etudid", -1)
etud: Identite = Identite.query.get_or_404(etudid)
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
2023-11-24 18:07:30 +01:00
# Gestion d'une assiduité unique (redirigé depuis le calendrier) TODO-Assiduites
2023-09-11 15:55:18 +02:00
assiduite_id: int = request.args.get("assiduite_id", -1)
2023-10-27 16:05:40 +02:00
# Préparation de la page
tableau = _prepare_tableau(
liste_assi.AssiJustifData.from_etudiants(
2023-11-24 18:07:30 +01:00
etud,
),
filename=f"assiduites-justificatifs-{etud.id}",
2023-11-24 18:07:30 +01:00
afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=0),
options=liste_assi.AssiDisplayOptions(show_module=True),
cache_key=f"tableau-etud-{etud.id}",
2023-11-24 18:07:30 +01:00
)
if not tableau[0]:
return tableau[1]
# Page HTML:
return render_template(
"assiduites/pages/liste_assiduites.j2",
assi_id=assiduite_id,
etud=etud,
tableau=tableau[1],
sco=ScoData(etud),
)
@bp.route("/bilan_etud")
2023-06-28 17:15:24 +02:00
@scodoc
2023-06-30 17:24:16 +02:00
@permission_required(Permission.ScoView)
2023-06-28 17:15:24 +02:00
def bilan_etud():
"""
bilan_etud Affichage de toutes les assiduites et justificatifs d'un etudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
2023-10-27 16:05:40 +02:00
# Récupération de l'étudiant
2023-06-28 17:15:24 +02:00
etudid = request.args.get("etudid", -1)
etud: Identite = Identite.query.get_or_404(etudid)
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
2023-10-27 16:05:40 +02:00
# Gestion des dates du bilan (par défaut l'année scolaire)
2023-12-29 13:57:44 +01:00
date_debut = scu.date_debut_annee_scolaire().strftime("%d/%m/%Y")
date_fin: str = scu.date_fin_annee_scolaire().strftime("%d/%m/%Y")
2023-06-28 17:15:24 +02:00
2023-10-27 16:05:40 +02:00
# Récupération de la métrique d'assiduité
2023-08-22 15:43:10 +02:00
assi_metric = scu.translate_assiduites_metric(
sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id),
)
2023-06-28 17:15:24 +02:00
2024-01-19 17:06:01 +01:00
# Récupération des assiduités et justificatifs de l'étudiant
data = liste_assi.AssiJustifData(
etud.assiduites.filter(
2024-02-27 21:51:43 +01:00
Assiduite.etat != scu.EtatAssiduite.PRESENT, Assiduite.est_just == False
2024-01-19 17:06:01 +01:00
),
etud.justificatifs.filter(
Justificatif.etat.in_(
[scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE]
)
),
)
table = _prepare_tableau(
data,
afficher_etu=False,
filename=f"Bilan assiduité {etud.nomprenom}",
titre="Bilan de l'assiduité de l'étudiant",
cache_key=f"tableau-etud-{etud.id}-bilan",
)
if not table[0]:
return table[1]
# Génération de la page HTML
return render_template(
"assiduites/pages/bilan_etud.j2",
assi_limit_annee=sco_preferences.get_preference(
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
2023-06-28 17:15:24 +02:00
),
assi_metric=assi_metric,
assi_seuil=_get_seuil(),
date_debut=date_debut,
date_fin=date_fin,
sco=ScoData(etud),
tableau=table[1],
)
2023-06-28 17:15:24 +02:00
@bp.route("/edit_justificatif_etud/<int:justif_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def edit_justificatif_etud(justif_id: int):
"""
Edition d'un justificatif.
Il faut de plus la permission pour voir/modifier la raison.
Args:
justif_id (int): l'identifiant du justificatif
Returns:
str: l'html généré
"""
justif = Justificatif.get_justificatif(justif_id)
form = AjoutJustificatifEtudForm(obj=justif)
# Set the default value for the etat field
if request.method == "GET":
form.date_debut.data = justif.date_debut.strftime("%d/%m/%Y")
form.date_fin.data = justif.date_fin.strftime("%d/%m/%Y")
if form.date_fin.data == form.date_debut.data:
# un seul jour: pas de date de fin, indique les heures
form.date_fin.data = ""
form.heure_debut.data = justif.date_debut.strftime("%H:%M")
form.heure_fin.data = justif.date_fin.strftime("%H:%M")
form.entry_date.data = (
justif.entry_date.strftime("%d/%m/%Y") if justif.entry_date else ""
)
form.entry_time.data = (
justif.entry_date.strftime("%H:%M") if justif.entry_date else ""
)
form.etat.data = str(justif.etat)
redirect_url = url_for(
"assiduites.liste_assiduites_etud",
scodoc_dept=g.scodoc_dept,
etudid=justif.etudiant.id,
)
if form.validate_on_submit():
if _record_justificatif_etud(justif.etudiant, form, justif):
return redirect(redirect_url)
# Fichiers
filenames, nb_files = justif.get_fichiers()
return render_template(
"assiduites/pages/ajout_justificatif_etud.j2",
assi_limit_annee=sco_preferences.get_preference(
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
),
can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView)
or current_user.id == justif.user_id,
etud=justif.etudiant,
filenames=filenames,
form=form,
justif=justif,
nb_files=nb_files,
title=f"Modification justificatif absence de {justif.etudiant.html_link_fiche()}",
redirect_url=redirect_url,
sco=ScoData(justif.etudiant),
scu=scu,
)
@bp.route("/ajout_justificatif_etud", methods=["GET", "POST"])
2023-06-20 15:50:56 +02:00
@scodoc
@permission_required(Permission.AbsChange)
2023-06-20 15:50:56 +02:00
def ajout_justificatif_etud():
"""
ajout_justificatif_etud : Affichage et création des justificatifs de l'étudiant
2023-06-20 15:50:56 +02:00
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
2023-12-12 03:05:31 +01:00
etud = Identite.get_etud(request.args.get("etudid"))
redirect_url = url_for(
"assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
2023-06-20 15:50:56 +02:00
)
2023-12-12 03:05:31 +01:00
form = AjoutJustificatifEtudForm()
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(redirect_url)
ok = _record_justificatif_etud(etud, form)
if ok:
return redirect(redirect_url)
is_html, tableau = _prepare_tableau(
liste_assi.AssiJustifData.from_etudiants(
2023-11-24 18:07:30 +01:00
etud,
),
2023-12-12 03:05:31 +01:00
filename=f"justificatifs-{etud.nom or ''}",
2023-11-24 18:07:30 +01:00
afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=2),
options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True),
2023-11-24 18:07:30 +01:00
afficher_options=False,
2023-12-12 03:05:31 +01:00
titre="Justificatifs enregistrés pour cet étudiant",
cache_key=f"tableau-etud-{etud.id}",
2023-11-24 18:07:30 +01:00
)
2023-12-12 03:05:31 +01:00
if not is_html:
return tableau
2023-11-24 18:07:30 +01:00
2023-12-12 03:05:31 +01:00
return render_template(
"assiduites/pages/ajout_justificatif_etud.j2",
assi_limit_annee=sco_preferences.get_preference(
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
2023-06-20 15:50:56 +02:00
),
2023-12-12 03:05:31 +01:00
etud=etud,
form=form,
title=f"Ajout justificatif absence pour {etud.html_link_fiche()}",
2023-12-12 03:05:31 +01:00
redirect_url=redirect_url,
sco=ScoData(etud),
scu=scu,
tableau=tableau,
)
def _record_justificatif_etud(
etud: Identite, form: AjoutJustificatifEtudForm, justif: Justificatif | None = None
2023-12-12 03:05:31 +01:00
) -> bool:
"""Enregistre les données du formulaire de saisie justificatif (et ses fichiers).
Returns ok if successfully recorded, else put error info in the form.
Format attendu des données du formulaire:
form.assi_etat.data : 'absent'
form.date_debut.data : '05/12/2023'
form.heure_debut.data : '09:06' (heure locale du serveur)
Si justif, modifie le justif existant, sinon en crée un nouveau
2023-12-12 03:05:31 +01:00
"""
(
ok,
dt_debut_tz_server,
dt_fin_tz_server,
dt_entry_date_tz_server,
2024-02-26 18:26:04 +01:00
) = _get_dates_from_assi_form(form, all_day=True)
2023-12-12 03:05:31 +01:00
if not ok:
log("_record_justificatif_etud: dates invalides")
form.set_error("Erreur: dates invalides")
return False
if not form.etat.data:
log("_record_justificatif_etud: etat invalide")
form.set_error("Erreur: état invalide")
return False
etat = int(form.etat.data)
if not scu.EtatJustificatif.is_valid_etat(etat):
log(f"_record_justificatif_etud: etat invalide ({etat})")
form.set_error("Erreur: état invalide")
2023-12-12 03:05:31 +01:00
return False
2023-12-12 03:05:31 +01:00
try:
message = ""
if justif:
form.date_debut.data = dt_debut_tz_server
form.date_fin.data = dt_fin_tz_server
form.entry_date.data = dt_entry_date_tz_server
if justif.edit_from_form(form):
message = "Justificatif modifié"
else:
message = "Pas de modification"
else:
justif = Justificatif.create_justificatif(
etud,
dt_debut_tz_server,
dt_fin_tz_server,
etat=etat,
raison=form.raison.data,
entry_date=dt_entry_date_tz_server,
user_id=current_user.id,
)
message = "Justificatif créé"
db.session.add(justif)
if not _upload_justificatif_files(justif, form):
flash("Erreur enregistrement fichiers")
log("problem in _upload_justificatif_files, rolling back")
2023-12-12 03:05:31 +01:00
db.session.rollback()
return False
db.session.commit()
# FIX TEMPORAIRE:
# on reprend toutes les assiduités et tous les justificatifs
# pour utiliser le "reset" (remise en "non_just") des assiduités
# (à terme, il faudrait ne recalculer que les assiduités impactées)
# VOIR TODO dans compute_assiduites_justified
compute_assiduites_justified(etud.id, reset=True)
scass.simple_invalidate_cache(justif.to_dict(), etud.id)
flash(message)
2023-12-12 03:05:31 +01:00
return True
except ScoValueError as exc:
log(f"_record_justificatif_etud: erreur {exc.args[0]}")
2023-12-12 03:05:31 +01:00
db.session.rollback()
form.set_error(f"Erreur: {exc.args[0]}")
2023-12-12 03:05:31 +01:00
return False
def _upload_justificatif_files(
just: Justificatif, form: AjoutJustificatifEtudForm
) -> bool:
"""Enregistre les fichiers du formulaire de création de justificatif"""
# Utilisation de l'archiver de justificatifs
archiver: JustificatifArchiver = JustificatifArchiver()
archive_name: str = just.fichier
try:
# On essaye de sauvegarder les fichiers
for file in form.fichiers.data or []:
archive_name, _ = archiver.save_justificatif(
just.etudiant,
filename=file.filename,
data=file.stream.read(),
archive_name=archive_name,
user_id=current_user.id,
)
flash(f"Fichier {file.filename} enregistré")
if form.fichiers.data:
# On actualise l'archive du justificatif
just.fichier = archive_name
db.session.add(just)
db.session.commit()
2023-12-12 03:05:31 +01:00
return True
except ScoValueError as exc:
log(
f"_upload_justificatif_files: error on {file.filename} for etud {just.etudid}"
)
form.set_error(f"Erreur sur fichier justificatif: {exc.args[0]}")
2023-12-12 03:05:31 +01:00
return False
2023-06-20 15:50:56 +02:00
2023-12-12 03:05:31 +01:00
@bp.route("/calendrier_assi_etud")
@scodoc
@permission_required(Permission.ScoView)
2023-12-12 03:05:31 +01:00
def calendrier_assi_etud():
"""
2023-12-12 03:05:31 +01:00
Affichage d'un calendrier de l'assiduité de l'étudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
2023-10-27 16:05:40 +02:00
# Récupération de l'étudiant
etudid = request.args.get("etudid", -1)
etud: Identite = Identite.query.get_or_404(etudid)
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
# Options
mode_demi: bool = scu.to_bool(request.args.get("mode_demi", "t"))
show_pres: bool = scu.to_bool(request.args.get("show_pres", "f"))
show_reta: bool = scu.to_bool(request.args.get("show_reta", "f"))
annee: int = int(request.args.get("annee", scu.annee_scolaire()))
2023-10-27 16:05:40 +02:00
# Récupération des années d'étude de l'étudiant
2023-11-15 17:34:13 +01:00
annees: list[int] = []
for ins in etud.formsemestre_inscriptions:
2024-02-27 08:58:09 +01:00
date_deb = ins.formsemestre.date_debut
date_fin = ins.formsemestre.date_fin
2023-11-15 17:34:13 +01:00
annees.extend(
2024-02-27 08:58:09 +01:00
[
scu.annee_scolaire_repr(date_deb.year, date_deb.month),
scu.annee_scolaire_repr(date_fin.year, date_fin.month),
]
2023-11-15 17:34:13 +01:00
)
annees = sorted(annees, reverse=True)
2023-10-27 16:05:40 +02:00
# Transformation en une liste "json"
# (sera utilisé pour générer le selecteur d'année)
2024-02-27 08:58:09 +01:00
annees_str: str = json.dumps(annees)
calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee)
2023-10-27 16:05:40 +02:00
# Peuplement du template jinja
return render_template(
"assiduites/pages/calendrier_assi_etud.j2",
sco=ScoData(etud),
annee=annee,
nonworkdays=_non_work_days(),
annees=annees_str,
calendrier=calendrier,
mode_demi=mode_demi,
show_pres=show_pres,
show_reta=show_reta,
)
@bp.route("/choix_date", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def choix_date() -> str:
2024-02-27 15:59:48 +01:00
"""
choix_date Choix de la date pour la saisie des assiduités
Route utilisée uniquement si la date courante n'est pas dans le semestre
concerné par la requête vers une des pages suivantes :
- saisie_assiduites_group
- visu_assiduites_group
"""
formsemestre_id = request.args.get("formsemestre_id")
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
group_ids = request.args.get("group_ids")
moduleimpl_id = request.args.get("moduleimpl_id")
form = ChoixDateForm(request.form)
if form.validate_on_submit():
if form.cancel.data:
return redirect(url_for("scodoc.index"))
# Vérifier si date dans semestre
ok: bool = False
try:
date: datetime.date = datetime.datetime.strptime(
form.date.data, "%d/%m/%Y"
).date()
if date < formsemestre.date_debut or date > formsemestre.date_fin:
form.set_error(
"La date sélectionnée n'est pas dans le semestre.", form.date
)
else:
ok = True
except ValueError:
form.set_error("Date invalide", form.date)
if ok:
return redirect(
url_for(
(
"assiduites.signal_assiduites_group"
if request.args.get("readonly") is None
else "assiduites.visu_assiduites_group"
),
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
group_ids=group_ids,
moduleimpl_id=moduleimpl_id,
jour=date.isoformat(),
)
)
return render_template(
"assiduites/pages/choix_date.j2",
form=form,
sco=ScoData(formsemestre=formsemestre),
deb=formsemestre.date_debut.strftime("%d/%m/%Y"),
fin=formsemestre.date_fin.strftime("%d/%m/%Y"),
)
@bp.route("/signal_assiduites_group")
2023-04-17 15:44:55 +02:00
@scodoc
@permission_required(Permission.AbsChange)
2023-04-17 15:44:55 +02:00
def signal_assiduites_group():
"""
signal_assiduites_group Saisie des assiduités des groupes pour le jour donné
2023-04-17 15:44:55 +02:00
Returns:
str: l'html généré
"""
2023-10-27 16:05:40 +02:00
# Récupération des paramètres de l'url
# formsemestre_id est optionnel si modimpl est indiqué
2023-04-17 15:44:55 +02:00
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
date: str = request.args.get("jour", datetime.date.today().isoformat())
heures: list[str] = [
request.args.get("heure_deb", ""),
request.args.get("heure_fin", ""),
]
2023-04-17 15:44:55 +02:00
group_ids: list[int] = request.args.get("group_ids", None)
2023-07-26 16:43:49 +02:00
if group_ids is None:
group_ids = []
else:
group_ids = group_ids.split(",")
map(str, group_ids)
# Vérification du moduleimpl_id
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError):
moduleimpl_id = None
2024-01-17 23:51:45 +01:00
if moduleimpl_id is not None and moduleimpl_id >= 0:
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
else:
modimpl = None
2023-07-26 16:43:49 +02:00
# Vérification du formsemestre_id
try:
formsemestre_id = int(formsemestre_id)
except (TypeError, ValueError):
formsemestre_id = None
if (formsemestre_id < 0 or formsemestre_id is None) and modimpl:
# si le module est spécifié mais pas le semestre:
formsemestre_id = modimpl.formsemestre_id
2023-10-27 16:05:40 +02:00
# Gestion des groupes
2023-07-26 16:43:49 +02:00
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids,
moduleimpl_id=moduleimpl_id,
formsemestre_id=formsemestre_id,
select_all_when_unspecified=True,
2023-07-26 16:43:49 +02:00
)
if not groups_infos.members:
return (
2023-09-21 08:46:21 +02:00
html_sco_header.sco_header(page_title="Saisie journalière de l'assiduité")
2023-07-26 16:43:49 +02:00
+ "<h3>Aucun étudiant ! </h3>"
+ html_sco_header.sco_footer()
)
# --- Filtrage par formsemestre ---
formsemestre_id = groups_infos.formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if formsemestre.dept_id != g.scodoc_dept_id:
abort(404, "groupes inexistants dans ce département")
2023-10-27 16:05:40 +02:00
# Récupération des étudiants des groupes
2023-07-26 16:43:49 +02:00
etuds = [
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
for m in groups_infos.members
]
# --- Vérification de la date ---
real_date = scu.is_iso_formated(date, True).date()
2023-09-13 08:59:54 +02:00
if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
# Si le jour est hors semestre, renvoyer vers choix date
return redirect(
url_for(
"assiduites.choix_date",
formsemestre_id=formsemestre_id,
group_ids=group_ids,
moduleimpl_id=moduleimpl_id,
scodoc_dept=g.scodoc_dept,
)
2023-09-13 08:59:54 +02:00
)
2023-07-26 16:43:49 +02:00
# --- Restriction en fonction du moduleimpl_id ---
if moduleimpl_id:
mod_inscrits = {
x["etudid"]
for x in sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=moduleimpl_id
)
}
etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits]
if etuds_inscrits_module:
etuds = etuds_inscrits_module
else:
# Si aucun etudiant n'est inscrit au module choisi...
moduleimpl_id = None
2023-10-27 16:05:40 +02:00
# Récupération du nom des/du groupe(s)
2023-07-26 16:43:49 +02:00
if groups_infos.tous_les_etuds_du_sem:
gr_tit = "en"
else:
if len(groups_infos.group_ids) > 1:
grp = "des groupes"
else:
grp = "du groupe"
gr_tit = (
grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
)
2023-10-27 16:05:40 +02:00
# Récupération du semestre en dictionnaire
sem = formsemestre.to_dict()
# Page HTML
return render_template(
"assiduites/pages/signal_assiduites_group.j2",
date=_dateiso_to_datefr(date),
defdem=_get_etuds_dem_def(formsemestre),
forcer_module=sco_preferences.get_preference(
"forcer_module",
2023-07-26 16:43:49 +02:00
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
2023-07-26 16:43:49 +02:00
),
formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin),
formsemestre_id=formsemestre_id,
gr_tit=gr_tit,
grp=sco_groups_view.menu_groups_choice(groups_infos),
minitimeline=_mini_timeline(),
moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
nonworkdays=_non_work_days(),
readonly="false",
sco=ScoData(formsemestre=formsemestre),
sem=sem["titre_num"],
timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
title="Saisie journalière des assiduités",
)
2023-07-26 16:43:49 +02:00
@bp.route("/visu_assiduites_group")
2023-07-26 16:43:49 +02:00
@scodoc
@permission_required(Permission.ScoView)
def visu_assiduites_group():
"""
2023-09-18 22:47:25 +02:00
Visualisation des assiduités des groupes pour le jour donné
dans le formsemestre_id et le moduleimpl_id
2023-07-26 16:43:49 +02:00
Returns:
str: l'html généré
"""
2023-10-27 16:05:40 +02:00
# Récupération des paramètres de la requête
2023-07-26 16:43:49 +02:00
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
date: str = request.args.get("jour", datetime.date.today().isoformat())
group_ids: list[int] = request.args.get("group_ids", None)
2023-04-17 15:44:55 +02:00
if group_ids is None:
group_ids = []
else:
group_ids = group_ids.split(",")
map(str, group_ids)
# Vérification du moduleimpl_id
2023-09-18 22:47:25 +02:00
if moduleimpl_id is not None:
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError) as exc:
raise ScoValueError("identifiant de moduleimpl invalide") from exc
2023-04-17 15:44:55 +02:00
# Vérification du formsemestre_id
2023-09-18 22:47:25 +02:00
if formsemestre_id is not None:
try:
formsemestre_id = int(formsemestre_id)
except (TypeError, ValueError) as exc:
raise ScoValueError("identifiant de formsemestre invalide") from exc
2023-04-17 15:44:55 +02:00
2023-10-27 16:05:40 +02:00
# Récupérations des/du groupe(s)
2023-04-17 15:44:55 +02:00
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id
)
if not groups_infos.members:
return (
2023-09-21 08:46:21 +02:00
html_sco_header.sco_header(page_title="Saisie journalière de l'assiduité")
2023-04-17 15:44:55 +02:00
+ "<h3>Aucun étudiant ! </h3>"
+ html_sco_header.sco_footer()
)
# --- Filtrage par formsemestre ---
formsemestre_id = groups_infos.formsemestre_id
2023-06-01 17:32:50 +02:00
2023-04-17 15:44:55 +02:00
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if formsemestre.dept_id != g.scodoc_dept_id:
abort(404, "groupes inexistants dans ce département")
2023-10-27 16:05:40 +02:00
# Récupération des étudiants du/des groupe(s)
2023-04-17 15:44:55 +02:00
etuds = [
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
for m in groups_infos.members
]
# --- Vérification de la date ---
real_date = scu.is_iso_formated(date, True).date()
if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
# Si le jour est hors semestre, renvoyer vers choix date
return redirect(
url_for(
"assiduites.choix_date",
formsemestre_id=formsemestre_id,
group_ids=group_ids,
moduleimpl_id=moduleimpl_id,
scodoc_dept=g.scodoc_dept,
readonly="true",
)
)
2023-04-17 15:44:55 +02:00
# --- Restriction en fonction du moduleimpl_id ---
if moduleimpl_id:
mod_inscrits = {
x["etudid"]
for x in sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=moduleimpl_id
)
}
etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits]
if etuds_inscrits_module:
etuds = etuds_inscrits_module
else:
# Si aucun etudiant n'est inscrit au module choisi...
moduleimpl_id = None
# --- Génération du HTML ---
2023-04-17 15:44:55 +02:00
if groups_infos.tous_les_etuds_du_sem:
gr_tit = "en"
else:
if len(groups_infos.group_ids) > 1:
grp = "des groupes"
else:
grp = "du groupe"
gr_tit = (
grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
)
2023-10-27 16:05:40 +02:00
# Récupération du semestre en dictionnaire
sem = formsemestre.to_dict()
return render_template(
"assiduites/pages/signal_assiduites_group.j2",
date=_dateiso_to_datefr(date),
defdem=_get_etuds_dem_def(formsemestre),
forcer_module=sco_preferences.get_preference(
"forcer_module",
2023-04-17 15:44:55 +02:00
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
2023-04-17 15:44:55 +02:00
),
formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin),
formsemestre_id=formsemestre_id,
gr_tit=gr_tit,
grp=sco_groups_view.menu_groups_choice(groups_infos),
minitimeline=_mini_timeline(),
moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
nonworkdays=_non_work_days(),
sem=sem["titre_num"],
timeline=_timeline(),
readonly="true",
sco=ScoData(formsemestre=formsemestre),
title="Saisie journalière de l'assiduité",
)
2023-04-17 15:44:55 +02:00
2023-12-09 15:53:45 +01:00
class RowEtudWithAssi(RowEtud):
"""Ligne de la table d'étudiants avec colonne Assiduité"""
def __init__(
self,
table: TableEtud,
etud: Identite,
etat_assiduite: str,
est_just: bool,
2023-12-09 15:53:45 +01:00
*args,
**kwargs,
):
super().__init__(table, etud, *args, **kwargs)
self.etat_assiduite = etat_assiduite
self.est_just = est_just
2023-12-09 15:53:45 +01:00
# remplace lien vers fiche par lien vers calendrier
self.target_url = url_for(
2023-12-12 03:05:31 +01:00
"assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
2023-12-09 15:53:45 +01:00
)
self.target_title = f"Calendrier de {etud.nomprenom}"
def add_etud_cols(self):
"""Ajoute colonnes pour cet étudiant"""
super().add_etud_cols()
self.add_cell(
"assi-type",
"Présence",
self.etat_assiduite,
"assi-type",
)
self.classes += ["row-assiduite", self.etat_assiduite.lower()]
if self.est_just:
self.classes += ["justifiee"]
2023-12-09 15:53:45 +01:00
2023-08-30 08:53:36 +02:00
@bp.route("/etat_abs_date")
2023-05-30 17:16:07 +02:00
@scodoc
2023-06-01 17:32:50 +02:00
@permission_required(Permission.ScoView)
2023-08-30 08:53:36 +02:00
def etat_abs_date():
2023-12-09 15:53:45 +01:00
"""Tableau de l'état d'assiduité d'un ou plusieurs groupes
sur la plage de dates date_debut, date_fin.
group_ids : ids de(s) groupe(s)
2023-12-09 15:53:45 +01:00
date_debut, date_fin: format ISO
evaluation_id: optionnel, évaluation concernée, pour titre et liens.
date_debut, date_fin en ISO
fmt : format export (xls, défaut html)
2023-12-09 15:53:45 +01:00
"""
2023-10-27 16:05:40 +02:00
2023-12-09 15:53:45 +01:00
# Récupération des paramètres de la requête
date_debut_str = request.args.get("date_debut")
date_fin_str = request.args.get("date_fin")
fmt = request.args.get("fmt", "html")
2023-12-09 15:53:45 +01:00
group_ids = request.args.getlist("group_ids", int)
evaluation_id = request.args.get("evaluation_id")
evaluation: Evaluation = (
Evaluation.query.get_or_404(evaluation_id)
if evaluation_id is not None
else None
)
2023-10-27 16:05:40 +02:00
# Vérification des dates
try:
date_debut = datetime.datetime.fromisoformat(date_debut_str)
except ValueError as exc:
raise ScoValueError("date_debut invalide") from exc
try:
date_fin = datetime.datetime.fromisoformat(date_fin_str)
except ValueError as exc:
raise ScoValueError("date_fin invalide") from exc
2023-10-27 16:05:40 +02:00
2023-12-09 15:53:45 +01:00
# Les groupes:
groups = [GroupDescr.query.get_or_404(group_id) for group_id in group_ids]
# Les étudiants de tous les groupes sélectionnés, flat list
2023-05-30 17:16:07 +02:00
etuds = [
2023-12-09 15:53:45 +01:00
etud for gr_etuds in [group.etuds for group in groups] for etud in gr_etuds
2023-05-30 17:16:07 +02:00
]
2023-12-09 15:53:45 +01:00
2023-10-27 16:05:40 +02:00
# Récupération des assiduites des étudiants
2023-05-30 17:16:07 +02:00
assiduites: Assiduite = Assiduite.query.filter(
2023-12-09 15:53:45 +01:00
Assiduite.etudid.in_([etud.id for etud in etuds])
2023-05-30 17:16:07 +02:00
)
2023-10-27 16:05:40 +02:00
# Filtrage des assiduités en fonction des dates données
assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin)
2023-05-30 17:16:07 +02:00
2023-12-09 15:53:45 +01:00
# Génération table
table = TableEtud(row_class=RowEtudWithAssi)
for etud in sorted(etuds, key=lambda e: e.sort_key):
2023-10-27 16:05:40 +02:00
# On récupère l'état de la première assiduité sur la période
2023-12-09 15:53:45 +01:00
assi = assiduites.filter_by(etudid=etud.id).first()
2023-05-30 17:16:07 +02:00
etat = ""
2024-01-22 17:01:01 +01:00
if assi is not None:
if assi.etat != scu.EtatAssiduite.PRESENT:
etat = scu.EtatAssiduite.inverse().get(assi.etat).name
row = table.row_class(table, etud, etat, assi.est_just)
row.add_etud_cols()
table.add_row(row)
2023-05-30 17:16:07 +02:00
if fmt.startswith("xls"):
return scu.send_file(
table.excel(),
filename=f"assiduite-eval-{date_debut.isoformat()}",
mime=scu.XLSX_MIMETYPE,
suffix=scu.XLSX_SUFFIX,
)
2023-12-09 15:53:45 +01:00
return render_template(
"assiduites/pages/etat_abs_date.j2",
date_debut=date_debut,
date_fin=date_fin,
evaluation=evaluation,
etuds=etuds,
group_title=", ".join(gr.get_nom_with_part("tous") for gr in groups),
sco=ScoData(),
table=table,
2023-05-30 17:16:07 +02:00
)
@bp.route("/visu_assi_group")
@scodoc
@permission_required(Permission.ScoView)
def visu_assi_group():
"""Visualisation de l'assiduité d'un groupe entre deux dates.
Paramètres:
- date_debut, date_fin (format ISO)
- fmt : format d'export, html (défaut) ou xls
- group_ids : liste des groupes
- formsemestre_modimpls_id: id d'un formasemestre, si fournit restreint les
comptages aux assiduités liées à des modules de ce formsemestre.
"""
2023-10-27 16:05:40 +02:00
# Récupération des paramètres de la requête
dates = {
"debut": request.args.get("date_debut"),
"fin": request.args.get("date_fin"),
}
formsemestre_modimpls_id = request.args.get("formsemestre_modimpls_id")
formsemestre_modimpls = (
None
if formsemestre_modimpls_id is None
else FormSemestre.get_formsemestre(formsemestre_modimpls_id)
)
fmt = request.args.get("fmt", "html")
group_ids: list[int] = request.args.get("group_ids", None)
if group_ids is None:
group_ids = []
else:
group_ids = group_ids.split(",")
map(str, group_ids)
2023-10-27 16:05:40 +02:00
# Récupération des groupes, du semestre et des étudiants
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
formsemestre = db.session.get(FormSemestre, groups_infos.formsemestre_id)
etuds = etuds_sorted_from_ids([m["etudid"] for m in groups_infos.members])
2023-10-27 16:05:40 +02:00
# Génération du tableau des assiduités
table: TableAssi = TableAssi(
etuds=etuds,
dates=list(dates.values()),
formsemestre=formsemestre,
formsemestre_modimpls=formsemestre_modimpls,
convert_values=(fmt == "html"),
)
2023-10-27 16:05:40 +02:00
# Export en XLS
if fmt.startswith("xls"):
return scu.send_file(
table.excel(),
filename=f"assiduite-{groups_infos.groups_filename}",
mime=scu.XLSX_MIMETYPE,
suffix=scu.XLSX_SUFFIX,
)
2023-10-27 16:05:40 +02:00
# récupération du/des noms du/des groupes
if groups_infos.tous_les_etuds_du_sem:
2023-07-25 14:03:09 +02:00
gr_tit = ""
grp = ""
else:
if len(groups_infos.group_ids) > 1:
grp = "des groupes"
else:
grp = "du groupe"
gr_tit = (
grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
)
2023-10-27 16:05:40 +02:00
# Génération de la page
return render_template(
"assiduites/pages/visu_assi_group.j2",
2023-08-22 15:43:10 +02:00
assi_metric=scu.translate_assiduites_metric(
scu.translate_assiduites_metric(
sco_preferences.get_preference(
"assi_metrique", dept_id=g.scodoc_dept_id
),
),
inverse=False,
short=False,
2023-08-13 22:40:16 +02:00
),
2023-11-15 14:11:47 +01:00
date_debut=_dateiso_to_datefr(dates["debut"]),
2023-11-17 11:36:15 +01:00
date_fin=_dateiso_to_datefr(dates["fin"]),
2023-08-13 22:40:16 +02:00
gr_tit=gr_tit,
group_ids=request.args.get("group_ids", None),
sco=ScoData(formsemestre=groups_infos.get_formsemestre()),
2023-08-13 22:40:16 +02:00
tableau=table.html(),
title=f"Assiduité {grp} {groups_infos.groups_titles}",
)
def _prepare_tableau(
data: liste_assi.AssiJustifData,
filename: str = "tableau-assiduites",
afficher_etu: bool = True,
filtre: liste_assi.AssiFiltre = None,
options: liste_assi.AssiDisplayOptions = None,
2023-11-24 18:07:30 +01:00
afficher_options: bool = True,
2023-12-12 03:05:31 +01:00
titre="Évènements enregistrés pour cet étudiant",
cache_key: str = "",
) -> tuple[bool, Response | str]:
"""
Prépare un tableau d'assiduités / justificatifs
2023-11-22 16:49:13 +01:00
Cette fonction récupère dans la requête les arguments :
show_pres : bool -> Affiche les présences, par défaut False
show_reta : bool -> Affiche les retard, par défaut False
show_desc : bool -> Affiche les descriptions, par défaut False
Returns:
tuple[bool | Reponse|str ]:
- bool : Vrai si la réponse est du Text/HTML
- Reponse : du Text/HTML ou Une Reponse (téléchargement fichier)
"""
2023-11-22 16:49:13 +01:00
show_pres: bool | str = request.args.get("show_pres", False)
show_reta: bool | str = request.args.get("show_reta", False)
show_desc: bool | str = request.args.get("show_desc", False)
2023-10-26 13:12:22 +02:00
2023-11-22 16:49:13 +01:00
nb_ligne_page: int = request.args.get("nb_ligne_page")
# Vérification de nb_ligne_page
try:
nb_ligne_page: int = int(nb_ligne_page)
except (ValueError, TypeError):
nb_ligne_page = liste_assi.ListeAssiJusti.NB_PAR_PAGE
2023-11-22 16:49:13 +01:00
page_number: int = request.args.get("n_page", 1)
# Vérification de page_number
try:
page_number: int = int(page_number)
except (ValueError, TypeError):
page_number = 1
fmt = request.args.get("fmt", "html")
# Ordre
ordre: tuple[str, str | bool] = None
ordre_col: str = request.args.get("order_col", None)
ordre_tri: str = request.args.get("order", None)
if ordre_col is not None and ordre_tri is not None:
ordre = (ordre_col, ordre_tri == "ascending")
if options is None:
options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions()
options.remplacer(
page=page_number,
nb_ligne_page=nb_ligne_page,
show_pres=show_pres,
show_reta=show_reta,
show_desc=show_desc,
show_etu=afficher_etu,
order=ordre,
)
2023-10-26 13:12:22 +02:00
table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
2023-11-24 18:07:30 +01:00
table_data=data,
options=options,
filtre=filtre,
no_pagination=fmt.startswith("xls"),
titre=cache_key,
2023-11-22 16:49:13 +01:00
)
2023-10-26 13:12:22 +02:00
if fmt.startswith("xls"):
return False, scu.send_file(
2023-10-26 13:12:22 +02:00
table.excel(),
filename=filename,
2023-10-26 13:12:22 +02:00
mime=scu.XLSX_MIMETYPE,
suffix=scu.XLSX_SUFFIX,
)
return True, render_template(
"assiduites/widgets/tableau.j2",
table=table,
2023-11-22 16:49:13 +01:00
total_pages=table.total_pages,
options=options,
2023-11-24 18:07:30 +01:00
afficher_options=afficher_options,
2023-12-12 03:05:31 +01:00
titre=titre,
2023-10-26 13:12:22 +02:00
)
@bp.route("/tableau_assiduite_actions", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def tableau_assiduite_actions():
"""Edition/suppression/information sur une assiduité ou un justificatif
type = "assiduite" | "justificatif"
action = "supprimer" | "details" | "justifier"
"""
obj_type: str = request.args.get("type", "assiduite")
action: str = request.args.get("action", "details")
obj_id: str = int(request.args.get("obj_id", -1))
objet: Assiduite | Justificatif
objet_name = ""
if obj_type == "assiduite":
objet: Assiduite = Assiduite.query.get_or_404(obj_id)
objet_name = scu.EtatAssiduite(objet.etat).version_lisible()
else:
objet: Justificatif = Justificatif.query.get_or_404(obj_id)
objet_name = "Justificatif"
# Suppression : attention, POST ou GET !
if action == "supprimer":
2023-12-07 12:38:47 +01:00
objet.supprime()
flash(f"{objet_name} supprimé")
return redirect(request.referrer)
# Justification d'une assiduité depuis le tableau
if action == "justifier" and obj_type == "assiduite":
# Création du justificatif correspondant
justificatif_correspondant: Justificatif = Justificatif.create_justificatif(
etudiant=objet.etudiant,
date_debut=objet.date_debut,
date_fin=objet.date_fin,
etat=scu.EtatJustificatif.VALIDE,
user_id=current_user.id,
)
compute_assiduites_justified(objet.etudiant.id, [justificatif_correspondant])
scass.simple_invalidate_cache(
justificatif_correspondant.to_dict(), objet.etudiant.id
)
flash(f"{objet_name} justifiée")
return redirect(request.referrer)
if request.method == "GET":
module: str | int = "" # moduleimpl_id ou chaine libre
if obj_type == "assiduite":
# Construction du menu module
2024-01-19 17:06:01 +01:00
module = _module_selector_multiple(objet.etudiant, objet.moduleimpl_id)
return render_template(
"assiduites/pages/tableau_assiduite_actions.j2",
action=action,
can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView)
or (obj_type == "justificatif" and current_user.id == objet.user_id),
etud=objet.etudiant,
moduleimpl=module,
obj_id=obj_id,
objet_name=objet_name,
objet=_preparer_objet(obj_type, objet),
sco=ScoData(etud=objet.etudiant),
title=f"Assiduité {objet.etudiant.nom_short}",
# type utilisé dans les actions modifier / détails (modifier.j2, details.j2)
type="Justificatif" if obj_type == "justificatif" else "Assiduité",
)
# ----- Cas POST
2023-11-24 18:07:30 +01:00
if obj_type == "assiduite":
try:
_action_modifier_assiduite(objet)
except ScoValueError as error:
raise ScoValueError(error.args[0], request.referrer) from error
flash("L'assiduité a bien été modifiée.")
else:
try:
_action_modifier_justificatif(objet)
except ScoValueError as error:
raise ScoValueError(error.args[0], request.referrer) from error
flash("Le justificatif a bien été modifié.")
return redirect(request.form["table_url"])
def _action_modifier_assiduite(assi: Assiduite):
form = request.form
# Gestion de l'état
etat = scu.EtatAssiduite.get(form["etat"])
if etat is not None:
assi.etat = etat
if etat == scu.EtatAssiduite.PRESENT:
assi.est_just = False
else:
assi.est_just = len(get_assiduites_justif(assi.assiduite_id, False)) > 0
# Gestion de la description
assi.description = form["description"]
possible_moduleimpl_id: str = form["moduleimpl_select"]
# Raise ScoValueError (si None et force module | Etudiant non inscrit | Module non reconnu)
assi.set_moduleimpl(possible_moduleimpl_id)
2023-11-24 18:07:30 +01:00
db.session.add(assi)
db.session.commit()
scass.simple_invalidate_cache(assi.to_dict(True), assi.etudid)
def _action_modifier_justificatif(justi: Justificatif):
"Modifie le justificatif avec les valeurs dans le form"
2023-11-24 18:07:30 +01:00
form = request.form
# Gestion des Dates
date_debut: datetime = scu.is_iso_formated(form["date_debut"], True)
date_fin: datetime = scu.is_iso_formated(form["date_fin"], True)
if date_debut is None or date_fin is None or date_fin < date_debut:
raise ScoValueError("Dates invalides", request.referrer)
justi.date_debut = date_debut
justi.date_fin = date_fin
# Gestion de l'état
etat = scu.EtatJustificatif.get(form["etat"])
if etat is not None:
justi.etat = etat
else:
raise ScoValueError("État invalide", request.referrer)
# Gestion de la raison
justi.raison = form["raison"]
# Gestion des fichiers
files = request.files.getlist("justi_fich")
if len(files) != 0:
files = request.files.values()
archive_name: str = justi.fichier
# Utilisation de l'archiver de justificatifs
archiver: JustificatifArchiver = JustificatifArchiver()
for fich in files:
archive_name, _ = archiver.save_justificatif(
justi.etudiant,
filename=fich.filename,
data=fich.stream.read(),
archive_name=archive_name,
user_id=current_user.id,
)
justi.fichier = archive_name
db.session.add(justi)
db.session.commit()
scass.compute_assiduites_justified(justi.etudid, reset=True)
scass.simple_invalidate_cache(justi.to_dict(True), justi.etudid)
def _preparer_objet(
obj_type: str, objet: Assiduite | Justificatif, sans_gros_objet: bool = False
) -> dict:
"Préparation d'un objet pour simplifier l'affichage jinja"
objet_prepare: dict = objet.to_dict()
if obj_type == "assiduite":
objet_prepare["etat"] = (
scu.EtatAssiduite(objet.etat).version_lisible().capitalize()
)
objet_prepare["real_etat"] = scu.EtatAssiduite(objet.etat).name.lower()
objet_prepare["description"] = (
"" if objet.description is None else objet.description
)
objet_prepare["description"] = objet_prepare["description"].strip()
# Gestion du module
objet_prepare["module"] = objet.get_module(True)
# Gestion justification
if not objet.est_just:
objet_prepare["justification"] = {"est_just": False}
else:
objet_prepare["justification"] = {"est_just": True, "justificatifs": []}
if not sans_gros_objet:
justificatifs: list[int] = get_assiduites_justif(
objet.assiduite_id, False
)
for justi_id in justificatifs:
justi: Justificatif = Justificatif.query.get(justi_id)
objet_prepare["justification"]["justificatifs"].append(
_preparer_objet("justificatif", justi, sans_gros_objet=True)
)
else: # objet == "justificatif"
justif: Justificatif = objet
objet_prepare["etat"] = (
scu.EtatJustificatif(justif.etat).version_lisible().capitalize()
)
objet_prepare["real_etat"] = scu.EtatJustificatif(justif.etat).name.lower()
objet_prepare["raison"] = "" if justif.raison is None else justif.raison
objet_prepare["raison"] = objet_prepare["raison"].strip()
objet_prepare["justification"] = {"assiduites": [], "fichiers": {}}
if not sans_gros_objet:
assiduites: list[int] = scass.justifies(justif)
for assi_id in assiduites:
assi: Assiduite = Assiduite.query.get(assi_id)
objet_prepare["justification"]["assiduites"].append(
_preparer_objet("assiduite", assi, sans_gros_objet=True)
)
# fichiers justificatifs archivés:
filenames, nb_files = justif.get_fichiers()
objet_prepare["justification"]["fichiers"] = {
"total": nb_files,
"filenames": filenames,
}
objet_prepare["date_fin"] = objet.date_fin.strftime("%d/%m/%y à %H:%M")
objet_prepare["real_date_fin"] = objet.date_fin.isoformat()
objet_prepare["date_debut"] = objet.date_debut.strftime("%d/%m/%y à %H:%M")
objet_prepare["real_date_debut"] = objet.date_debut.isoformat()
objet_prepare["entry_date"] = objet.entry_date.strftime("%d/%m/%y à %H:%M")
objet_prepare["etud_nom"] = objet.etudiant.nomprenom
if objet.user_id is not None:
user: User = User.query.get(objet.user_id)
objet_prepare["saisie_par"] = user.get_nomprenom()
else:
objet_prepare["saisie_par"] = "Inconnu"
return objet_prepare
@bp.route("/signal_assiduites_diff")
2023-06-01 17:32:50 +02:00
@scodoc
@permission_required(Permission.AbsChange)
2023-06-01 17:32:50 +02:00
def signal_assiduites_diff():
"""TODO documenter
Utilisé notamment par "Saisie différée" sur tableau de bord semetstre"
"""
2023-10-27 16:05:40 +02:00
# Récupération des paramètres de la requête
2023-06-01 17:32:50 +02:00
group_ids: list[int] = request.args.get("group_ids", None)
formsemestre_id: int = request.args.get("formsemestre_id", -1)
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
date: str = request.args.get("jour", datetime.date.today().isoformat())
date_deb: str = request.args.get("date_deb")
date_fin: str = request.args.get("date_fin")
semaine: str = request.args.get("semaine")
2023-10-27 16:05:40 +02:00
# Dans le cas où on donne une semaine plutot qu'un jour
if semaine is not None:
2023-10-27 16:05:40 +02:00
# On génère la semaine iso à partir de l'anne scolaire.
semaine = (
f"{scu.annee_scolaire()}-W{semaine}" if "W" not in semaine else semaine
)
2023-10-27 16:05:40 +02:00
# On met à jour les dates avec le date de debut et fin de semaine
date_deb: datetime.date = datetime.datetime.strptime(
semaine + "-1", "%Y-W%W-%w"
)
date_fin: datetime.date = date_deb + datetime.timedelta(days=6)
2024-01-19 17:06:01 +01:00
etudiants: list[Identite] = []
2023-06-01 17:32:50 +02:00
# --- Vérification de la date ---
real_date = scu.is_iso_formated(date, True).date()
if real_date < formsemestre.date_debut:
date = formsemestre.date_debut.isoformat()
elif real_date > formsemestre.date_fin:
date = formsemestre.date_fin.isoformat()
2023-10-27 16:05:40 +02:00
# Vérification des groupes
2023-06-01 17:32:50 +02:00
if group_ids is None:
group_ids = []
else:
group_ids = group_ids.split(",")
map(str, group_ids)
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
)
if not groups_infos.members:
return (
html_sco_header.sco_header(page_title="Assiduité: saisie différée")
+ "<h3>Aucun étudiant ! </h3>"
+ html_sco_header.sco_footer()
)
2023-10-27 16:05:40 +02:00
# Récupération des étudiants
2023-06-01 17:32:50 +02:00
etudiants.extend(
2024-01-19 17:06:01 +01:00
[Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members]
2023-06-01 17:32:50 +02:00
)
2024-01-19 17:06:01 +01:00
etudiants = list(sorted(etudiants, key=lambda etud: etud.sort_key))
2023-06-01 17:32:50 +02:00
if groups_infos.tous_les_etuds_du_sem:
gr_tit = "en"
else:
if len(groups_infos.group_ids) > 1:
grp = "des groupes"
else:
grp = "du groupe"
gr_tit = (
grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
)
return render_template(
"assiduites/pages/signal_assiduites_diff.j2",
defaultDates=_get_days_between_dates(date_deb, date_fin),
defdem=_get_etuds_dem_def(formsemestre),
diff=_differee(
etudiants=etudiants,
moduleimpl_select=_module_selector(
formsemestre, request.args.get("moduleimpl_id", None)
),
date=date,
periode={
"deb": formsemestre.date_debut.isoformat(),
"fin": formsemestre.date_fin.isoformat(),
},
2023-06-01 17:32:50 +02:00
),
gr=gr_tit,
nonworkdays=_non_work_days(),
sco=ScoData(formsemestre=formsemestre),
sem=formsemestre.titre_num(),
timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"),
timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"),
timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"),
)
2023-06-01 17:32:50 +02:00
2023-12-10 20:59:32 +01:00
@bp.route("/signale_evaluation_abs/<int:evaluation_id>/<int:etudid>")
@scodoc
2023-12-10 20:59:32 +01:00
@permission_required(Permission.AbsChange)
def signale_evaluation_abs(etudid: int = None, evaluation_id: int = None):
"""
Signale l'absence d'un étudiant à une évaluation
2023-12-10 20:59:32 +01:00
Si la durée de l'évaluation est inférieure à 1 jour
l'absence sera sur la période de l'évaluation
sinon l'utilisateur sera redirigé vers la page de saisie des absences de l'étudiant
"""
etud = Identite.get_etud(etudid)
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
delta: datetime.timedelta = evaluation.date_fin - evaluation.date_debut
2023-10-27 16:05:40 +02:00
# Si l'évaluation dure plus qu'un jour alors on redirige vers la page de saisie etudiant
if delta > datetime.timedelta(days=1):
# rediriger vers page saisie
return redirect(
url_for(
2023-12-05 21:04:38 +01:00
"assiduites.ajout_assiduite_etud",
etudid=etudid,
evaluation_id=evaluation.id,
2023-12-07 12:38:47 +01:00
date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"),
date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
moduleimpl_id=evaluation.moduleimpl.id,
saisie_eval="true",
scodoc_dept=g.scodoc_dept,
)
)
2023-10-27 16:05:40 +02:00
# Sinon on créé l'assiduité
assiduite_unique: Assiduite | None = None
try:
assiduite_unique = Assiduite.create_assiduite(
etud=etud,
date_debut=scu.localize_datetime(evaluation.date_debut),
date_fin=scu.localize_datetime(evaluation.date_fin),
etat=scu.EtatAssiduite.ABSENT,
moduleimpl=evaluation.moduleimpl,
)
2023-12-10 20:59:32 +01:00
except ScoValueError as exc:
2023-10-27 16:05:40 +02:00
# En cas d'erreur
2023-12-10 20:59:32 +01:00
msg: str = exc.args[0]
if "Duplication" in msg:
msg = """Une autre saisie concerne déjà cette période.
En cliquant sur continuer vous serez redirigé vers la page de
2023-12-05 21:04:38 +01:00
saisie de l'assiduité de l'étudiant."""
dest: str = url_for(
2023-12-05 21:04:38 +01:00
"assiduites.ajout_assiduite_etud",
etudid=etudid,
evaluation_id=evaluation.id,
2024-01-19 17:06:01 +01:00
date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"),
date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
moduleimpl_id=evaluation.moduleimpl.id,
saisie_eval="true",
scodoc_dept=g.scodoc_dept,
duplication="oui",
)
2023-12-10 20:59:32 +01:00
raise ScoValueError(msg, dest) from exc
if assiduite_unique is not None:
db.session.add(assiduite_unique)
db.session.commit()
2023-12-10 20:59:32 +01:00
# on flash puis on revient sur la page de l'évaluation
flash("L'absence a bien été créée")
# rediriger vers la page d'évaluation
return redirect(
url_for(
"notes.evaluation_check_absences_html",
evaluation_id=evaluation.id,
scodoc_dept=g.scodoc_dept,
)
)
def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
"""Génère la liste des assiduités d'un étudiant pour le bulletin mail"""
2023-10-27 16:05:40 +02:00
# On récupère la métrique d'assiduité
metrique: str = scu.translate_assiduites_metric(
sco_preferences.get_preference("assi_metrique", formsemestre_id=semestre.id),
)
2023-10-27 16:05:40 +02:00
# On récupère le nombre maximum de ligne d'assiduité
max_nb: int = int(
sco_preferences.get_preference(
"bul_mail_list_abs_nb", formsemestre_id=semestre.id
)
)
2023-10-27 16:05:40 +02:00
# On récupère les assiduités et les justificatifs de l'étudiant
assiduites = scass.filter_by_formsemestre(
etud.assiduites, Assiduite, semestre
).order_by(Assiduite.entry_date.desc())
justificatifs = scass.filter_by_formsemestre(
etud.justificatifs, Justificatif, semestre
).order_by(Justificatif.entry_date.desc())
2023-10-27 16:05:40 +02:00
# On calcule les statistiques
stats: dict = scass.get_assiduites_stats(
assiduites, metric=metrique, filtered={"split": True}
)
# On sépare :
2023-10-27 16:05:40 +02:00
# - abs_j = absences justifiées
# - abs_nj = absences non justifiées
# - retards = les retards
# - justifs = les justificatifs
abs_j: list[str] = [
{"date": _get_date_str(assi.date_debut, assi.date_fin)}
for assi in assiduites
if assi.etat == scu.EtatAssiduite.ABSENT and assi.est_just is True
]
abs_nj: list[str] = [
{"date": _get_date_str(assi.date_debut, assi.date_fin)}
for assi in assiduites
if assi.etat == scu.EtatAssiduite.ABSENT and assi.est_just is False
]
retards: list[str] = [
{"date": _get_date_str(assi.date_debut, assi.date_fin)}
for assi in assiduites
if assi.etat == scu.EtatAssiduite.RETARD
]
justifs: list[dict[str, str]] = [
{
"date": _get_date_str(justi.date_debut, justi.date_fin),
"raison": "" if justi.raison is None else justi.raison,
"etat": {
scu.EtatJustificatif.VALIDE: "justificatif valide",
scu.EtatJustificatif.NON_VALIDE: "justificatif invalide",
scu.EtatJustificatif.ATTENTE: "justificatif en attente de validation",
scu.EtatJustificatif.MODIFIE: "justificatif ayant été modifié",
}.get(justi.etat),
}
for justi in justificatifs
]
return render_template(
"assiduites/widgets/liste_assiduites_mail.j2",
abs_j=abs_j[:max_nb],
abs_nj=abs_nj[:max_nb],
retards=retards[:max_nb],
justifs=justifs[:max_nb],
stats=stats,
metrique=scu.translate_assiduites_metric(metrique, short=True, inverse=False),
metric=metrique,
)
# --- Fonctions internes ---
2023-11-15 14:11:47 +01:00
def _dateiso_to_datefr(date_iso: str) -> str:
"""
_dateiso_to_datefr Transforme une date iso en date format français
Args:
date_iso (str): date au format iso (YYYY-MM-DD)
Raises:
ValueError: Si l'argument `date_iso` n'est pas au bon format
Returns:
str: date au format français (DD/MM/YYYY)
"""
regex_date_iso: str = r"^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])$"
# Vérification de la date_iso
if not re.match(regex_date_iso, date_iso):
raise ValueError(
f"La dateiso passée en paramètre [{date_iso}] n'est pas valide."
)
return f"{date_iso[8:10]}/{date_iso[5:7]}/{date_iso[0:4]}"
def _get_date_str(deb: datetime.datetime, fin: datetime.datetime) -> str:
2023-10-27 16:05:40 +02:00
"""
_get_date_str transforme une période en chaîne lisible
Args:
deb (datetime.datetime): date de début
fin (datetime.datetime): date de fin
Returns:
str:
"le dd/mm/yyyy de hh:MM à hh:MM" si les deux date sont sur le même jour
"du dd/mm/yyyy hh:MM audd/mm/yyyy hh:MM" sinon
"""
if deb.date() == fin.date():
temps = deb.strftime("%d/%m/%Y %H:%M").split(" ") + [fin.strftime("%H:%M")]
return f"le {temps[0]} de {temps[1]} à {temps[2]}"
return f'du {deb.strftime("%d/%m/%Y %H:%M")} au {fin.strftime("%d/%m/%Y %H:%M")}'
2023-10-27 16:05:40 +02:00
def _get_days_between_dates(deb: str, fin: str) -> str:
"""
_get_days_between_dates récupère tous les jours entre deux dates
Args:
deb (str): date de début
fin (str): date de fin
Returns:
str: une chaine json représentant une liste des jours
['date_iso','date_iso2', ...]
"""
if deb is None or fin is None:
return "null"
try:
if isinstance(deb, str) and isinstance(fin, str):
date_deb: datetime.date = datetime.date.fromisoformat(deb)
date_fin: datetime.date = datetime.date.fromisoformat(fin)
else:
date_deb, date_fin = deb.date(), fin.date()
except ValueError:
return "null"
dates: list[str] = []
while date_deb <= date_fin:
dates.append(f'"{date_deb.isoformat()}"')
date_deb = date_deb + datetime.timedelta(days=1)
return f"[{','.join(dates)}]"
def _differee(
2023-10-27 16:05:40 +02:00
etudiants: list[dict],
moduleimpl_select: str,
date: str = None,
periode: dict[str, str] = None,
formsemestre_id: int = None,
) -> str:
"""
_differee Génère un tableau de saisie différé
Args:
etudiants (list[dict]): la liste des étudiants (représentés par des dictionnaires)
moduleimpl_select (str): l'html représentant le selecteur de module
date (str, optional): la première date à afficher. Defaults to None.
2024-02-27 15:59:48 +01:00
periode (dict[str, str], optional):La période par défaut de la première colonne.
formsemestre_id (int, optional): l'id du semestre pour le selecteur de module.
2023-10-27 16:05:40 +02:00
Returns:
str: le widget (html/css/js)
"""
if date is None:
date = datetime.date.today().isoformat()
forcer_module = sco_preferences.get_preference(
"forcer_module",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
)
assi_etat_defaut = sco_preferences.get_preference(
"assi_etat_defaut",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
)
periode_defaut = sco_preferences.get_preference(
"periode_defaut",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
)
return render_template(
2023-06-20 08:33:49 +02:00
"assiduites/widgets/differee.j2",
etudiants=etudiants,
assi_etat_defaut=assi_etat_defaut,
periode_defaut=periode_defaut,
forcer_module=forcer_module,
moduleimpl_select=moduleimpl_select,
date=date,
periode=periode,
)
2023-10-27 16:05:40 +02:00
def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> str:
2023-04-17 15:44:55 +02:00
"""
_module_selector Génère un HTMLSelectElement à partir des moduleimpl du formsemestre
Args:
formsemestre (FormSemestre): Le formsemestre d'où les moduleimpls seront pris.
Returns:
str: La représentation str d'un HTMLSelectElement
"""
2023-10-27 16:05:40 +02:00
# récupération des ues du semestre
2023-04-17 15:44:55 +02:00
ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
2023-10-27 16:05:40 +02:00
ues = ntc.get_ues_stat_dict()
2023-04-17 15:44:55 +02:00
modimpls_list: list[dict] = []
for ue in ues:
2023-10-27 16:05:40 +02:00
# Ajout des moduleimpl de chaque ue dans la liste des moduleimpls
2023-04-17 15:44:55 +02:00
modimpls_list += ntc.get_modimpls_dict(ue_id=ue["ue_id"])
2023-10-27 16:05:40 +02:00
# prévoie la sélection par défaut d'un moduleimpl s'il a été passé en paramètre
selected = "" if moduleimpl_id is not None else "selected"
2023-04-17 15:44:55 +02:00
2023-10-27 16:05:40 +02:00
modules: list[dict[str, str | int]] = []
# Récupération de l'id et d'un nom lisible pour chaque moduleimpl
2023-04-17 15:44:55 +02:00
for modimpl in modimpls_list:
modname: str = (
(modimpl["module"]["code"] or "")
+ " "
+ (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "")
)
modules.append({"moduleimpl_id": modimpl["moduleimpl_id"], "name": modname})
return render_template(
"assiduites/widgets/moduleimpl_selector.j2",
formsemestre_id=formsemestre.id,
modules=modules,
moduleimpl_id=moduleimpl_id,
selected=selected,
2023-04-17 15:44:55 +02:00
)
2024-01-19 17:06:01 +01:00
def _module_selector_multiple(
etud: Identite, moduleimpl_id: int = None, only_form: FormSemestre = None
) -> str:
"""menu HTML <select> pour choix moduleimpl
Prend les semestres de l'année, sauf si only_form est indiqué.
"""
2024-01-19 17:06:01 +01:00
modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
choices = OrderedDict()
2024-01-19 17:06:01 +01:00
for formsemestre_id in modimpls_by_formsemestre:
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if only_form is not None and formsemestre != only_form:
continue
# indique le nom du semestre dans le menu (optgroup)
choices[formsemestre.titre_annee()] = [
{
"moduleimpl_id": m.id,
"name": f"{m.module.code} {m.module.abbrev or m.module.titre or ''}",
}
for m in modimpls_by_formsemestre[formsemestre_id]
if m.module.ue.type == UE_STANDARD
]
if formsemestre.est_courant():
choices.move_to_end(formsemestre.titre_annee(), last=False)
2024-01-19 17:06:01 +01:00
return render_template(
"assiduites/widgets/moduleimpl_selector_multiple.j2",
choices=choices,
moduleimpl_id=moduleimpl_id,
)
def _timeline(formsemestre_id: int = None, heures=None) -> str:
2023-10-27 16:05:40 +02:00
"""
_timeline retourne l'html de la timeline
Args:
formsemestre_id (int, optional): un formsemestre. Defaults to None.
Le formsemestre sert à obtenir la période par défaut de la timeline
sinon ce sera de 2 heure dès le début de la timeline
Returns:
str: l'html en chaîne de caractères
"""
2023-04-25 22:59:06 +02:00
return render_template(
2023-06-20 08:33:49 +02:00
"assiduites/widgets/timeline.j2",
t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"),
t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"),
2023-06-02 17:19:55 +02:00
tick_time=ScoDocSiteConfig.get("assi_tick_time", 15),
periode_defaut=sco_preferences.get_preference(
"periode_defaut", formsemestre_id
),
heures=heures,
2023-04-25 22:59:06 +02:00
)
2023-10-27 16:05:40 +02:00
def _mini_timeline() -> str:
"""
_mini_timeline Retourne l'html lié au mini timeline d'assiduités
Returns:
str: l'html en chaîne de caractères
"""
return render_template(
2023-06-20 08:33:49 +02:00
"assiduites/widgets/minitimeline.j2",
t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"),
t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"),
)
def _non_work_days() -> str:
"""Abbréviation des jours non travaillés: "'sam','dim'".
donnés par les préférences du département
"""
non_travail = sco_preferences.get_preference("non_travail")
non_travail = non_travail.replace(" ", "").split(",")
return ",".join([f"'{i.lower()}'" for i in non_travail])
def _get_seuil() -> int:
"""Seuil d'alerte des absences (en unité de la métrique),
tel que fixé dans les préférences du département."""
2023-06-28 17:15:24 +02:00
return sco_preferences.get_preference("assi_seuil", dept_id=g.scodoc_dept_id)
def _get_etuds_dem_def(formsemestre) -> str:
"""Une chaine json donnant les étudiants démissionnaires ou défaillants
du formsemestre, sous la forme
'{"516" : "D", ... }'
"""
return (
"{"
+ ", ".join(
[
f'"{ins.etudid}" : "{ins.etat}"'
for ins in formsemestre.inscriptions
if ins.etat != scu.INSCRIT
]
)
+ "}"
)
# --- Gestion du calendrier ---
def generate_calendar(
etudiant: Identite,
annee: int = None,
) -> dict[str, list["Jour"]]:
2024-02-27 15:59:48 +01:00
"""
Génère le calendrier d'assiduité de l'étudiant pour une année scolaire donnée
"""
# Si pas d'année alors on prend l'année scolaire en cours
if annee is None:
annee = scu.annee_scolaire()
# On prend du 01/09 au 31/08
date_debut: datetime.datetime = datetime.datetime(annee, 9, 1, 0, 0)
date_fin: datetime.datetime = datetime.datetime(annee + 1, 8, 31, 23, 59)
# Filtrage des assiduités et des justificatifs en fonction de la periode / année
etud_assiduites: Query = scass.filter_by_date(
etudiant.assiduites,
Assiduite,
date_deb=date_debut,
date_fin=date_fin,
)
etud_justificatifs: Query = scass.filter_by_date(
etudiant.justificatifs,
Justificatif,
date_deb=date_debut,
date_fin=date_fin,
)
# Récupération des jours de l'année et de leurs assiduités/justificatifs
annee_par_mois: dict[str, list[Jour]] = _organize_by_month(
_get_dates_between(
deb=date_debut.date(),
fin=date_fin.date(),
),
etud_assiduites,
etud_justificatifs,
)
return annee_par_mois
class Jour:
"""Jour
Jour du calendrier
get_nom : retourne le numéro et le nom du Jour (ex: M19 / Mer 19)
"""
def __init__(self, date: datetime.date, assiduites: Query, justificatifs: Query):
self.date = date
self.assiduites = assiduites
self.justificatifs = justificatifs
def get_nom(self, mode_demi: bool = True) -> str:
2024-02-27 15:59:48 +01:00
"""
Renvoie le nom du jour
"M19" ou "Mer 19"
"""
str_jour: str = scu.DAY_NAMES[self.date.weekday()].capitalize()
2024-02-27 15:59:48 +01:00
return (
f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour[:3]+' '}"
+ f"{self.date.day}"
)
def get_date(self) -> str:
2024-02-27 15:59:48 +01:00
"""
Renvoie la date du jour au format "dd/mm/yyyy"
"""
return self.date.strftime("%d/%m/%Y")
def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str:
2024-02-27 15:59:48 +01:00
"""
Retourne la classe css du jour (mode normal)
"""
etat = ""
est_just = ""
if self.is_non_work():
return "color nonwork"
etat = self._get_color_assiduites_cascade(
self._get_etats_from_assiduites(self.assiduites),
show_pres=show_pres,
show_reta=show_reta,
)
est_just = self._get_color_justificatifs_cascade(
self._get_etats_from_justificatifs(self.justificatifs),
)
return f"color {etat} {est_just}"
def get_demi_class(
self, matin: bool, show_pres: bool = False, show_reta: bool = False
) -> str:
2024-02-27 15:59:48 +01:00
"""
Renvoie la class css de la demi journée
"""
2024-02-27 15:59:48 +01:00
heure_midi = scass.str_to_time(ScoDocSiteConfig.get("assi_lunch_time", "13:00"))
if matin:
2024-02-27 15:59:48 +01:00
heure_matin = scass.str_to_time(
ScoDocSiteConfig.get("assi_morning_time", "08:00")
)
log(
f'{ScoDocSiteConfig.get("assi_morning_time", "08:00")=}{heure_matin=} {type(heure_matin)=}'
)
matin = (
# date debut
scu.localize_datetime(
datetime.datetime.combine(self.date, heure_matin)
),
# date fin
scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
)
assiduites_matin = [
assi
for assi in self.assiduites
2024-02-27 08:58:09 +01:00
if scu.is_period_overlapping(
(assi.date_debut, assi.date_fin), matin, bornes=False
)
]
justificatifs_matin = [
justi
for justi in self.justificatifs
2024-02-27 08:58:09 +01:00
if scu.is_period_overlapping(
(justi.date_debut, justi.date_fin), matin, bornes=False
)
]
etat = self._get_color_assiduites_cascade(
self._get_etats_from_assiduites(assiduites_matin),
show_pres=show_pres,
show_reta=show_reta,
)
est_just = self._get_color_justificatifs_cascade(
self._get_etats_from_justificatifs(justificatifs_matin),
)
return f"color {etat} {est_just}"
2024-02-27 15:59:48 +01:00
heure_soir = scass.str_to_time(
ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
)
# séparation en demi journées
aprem = (
# date debut
scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
# date fin
scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)),
)
assiduites_aprem = [
assi
for assi in self.assiduites
2024-02-27 08:58:09 +01:00
if scu.is_period_overlapping(
(assi.date_debut, assi.date_fin), aprem, bornes=False
)
]
justificatifs_aprem = [
justi
for justi in self.justificatifs
2024-02-27 08:58:09 +01:00
if scu.is_period_overlapping(
(justi.date_debut, justi.date_fin), aprem, bornes=False
)
]
etat = self._get_color_assiduites_cascade(
self._get_etats_from_assiduites(assiduites_aprem),
show_pres=show_pres,
show_reta=show_reta,
)
est_just = self._get_color_justificatifs_cascade(
self._get_etats_from_justificatifs(justificatifs_aprem),
)
return f"color {etat} {est_just}"
def has_assiduites(self) -> bool:
2024-02-27 15:59:48 +01:00
"""
Renverra True si le jour a des assiduités
"""
return self.assiduites.count() > 0
def generate_minitimeline(self) -> str:
2024-02-27 15:59:48 +01:00
"""
Génère la minitimeline du jour
"""
# Récupérer le référenciel de la timeline
heure_matin: datetime.timedelta = _time_to_timedelta(
scass.str_to_time(ScoDocSiteConfig.get("assi_morning_time", "08:00"))
)
heure_soir: datetime.timedelta = _time_to_timedelta(
scass.str_to_time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00"))
)
# longueur_timeline = heure_soir - heure_matin
longueur_timeline: datetime.timedelta = heure_soir - heure_matin
# chaque block d'assiduité est défini par:
# longueur = ( (fin-deb) / longueur_timeline ) * 100
# emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100
2024-02-27 08:58:09 +01:00
# longueur + emplacement = 100% sinon on réduit longueur
assiduite_blocks: list[dict[str, float | str]] = []
for assi in self.assiduites:
deb: datetime.timedelta = _time_to_timedelta(
assi.date_debut.time()
if assi.date_debut.date() == self.date
else heure_matin
)
fin: datetime.timedelta = _time_to_timedelta(
assi.date_fin.time()
if assi.date_fin.date() == self.date
else heure_soir
)
emplacement: float = ((deb - heure_matin) / longueur_timeline) * 100
2024-02-27 08:58:09 +01:00
longueur: float = ((fin - deb) / longueur_timeline) * 100
if longueur + emplacement > 100:
longueur = 100 - emplacement
etat: str = scu.EtatAssiduite(assi.etat).name.lower()
est_just: str = "est_just" if assi.est_just else ""
assiduite_blocks.append(
{
"longueur": longueur,
"emplacement": emplacement,
"etat": etat,
"est_just": est_just,
"bubble": _generate_assiduite_bubble(assi),
"id": assi.assiduite_id,
}
)
return render_template(
"assiduites/widgets/minitimeline_simple.j2",
assi_blocks=assiduite_blocks,
)
def is_non_work(self):
2024-02-27 15:59:48 +01:00
"""
Renvoie True si le jour est un jour non travaillé
(en fonction de la préférence du département)
"""
return self.date.weekday() in scu.NonWorkDays.get_all_non_work_days(
dept_id=g.scodoc_dept_id
)
def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]:
2024-02-27 15:59:48 +01:00
return list(set(scu.EtatAssiduite(assi.etat) for assi in assiduites))
def _get_etats_from_justificatifs(
self, justificatifs: Query
) -> list[scu.EtatJustificatif]:
2024-02-27 15:59:48 +01:00
return list(set(scu.EtatJustificatif(justi.etat) for justi in justificatifs))
def _get_color_assiduites_cascade(
self,
etats: list[scu.EtatAssiduite],
show_pres: bool = False,
show_reta: bool = False,
) -> str:
if scu.EtatAssiduite.ABSENT in etats:
return "absent"
if scu.EtatAssiduite.RETARD in etats and show_reta:
return "retard"
if scu.EtatAssiduite.PRESENT in etats and show_pres:
return "present"
return "sans_etat"
def _get_color_justificatifs_cascade(
self,
etats: list[scu.EtatJustificatif],
) -> str:
if scu.EtatJustificatif.VALIDE in etats:
return "est_just"
if scu.EtatJustificatif.ATTENTE in etats:
return "attente"
if scu.EtatJustificatif.MODIFIE in etats:
return "modifie"
if scu.EtatJustificatif.NON_VALIDE in etats:
return "invalide"
return ""
def _get_dates_between(deb: datetime.date, fin: datetime.date) -> list[datetime.date]:
resultat = []
date_actuelle = deb
while date_actuelle <= fin:
resultat.append(date_actuelle)
date_actuelle += datetime.timedelta(days=1)
return resultat
def _organize_by_month(days, assiduites, justificatifs) -> dict[str, list[Jour]]:
"""
Organiser les dates par mois.
"""
organized = {}
for date in days:
# Récupérer le mois en français
month = scu.MONTH_NAMES_ABBREV[date.month - 1]
# Ajouter le jour à la liste correspondante au mois
if month not in organized:
organized[month] = []
date_assiduites: Query = scass.filter_by_date(
assiduites,
Assiduite,
date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
)
date_justificatifs: Query = scass.filter_by_date(
justificatifs,
Justificatif,
date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
)
# On génère un `Jour` composé d'une date, et des assiduités/justificatifs du jour
jour: Jour = Jour(date, date_assiduites, date_justificatifs)
organized[month].append(jour)
return organized
def _time_to_timedelta(t: datetime.time) -> datetime.timedelta:
if isinstance(t, datetime.timedelta):
return t
return datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second)
def _generate_assiduite_bubble(assiduite: Assiduite) -> str:
# Récupérer informations modules impl
moduleimpl_infos: str = assiduite.get_module(traduire=True)
# Récupérer informations saisie
saisie: str = assiduite.get_saisie()
2024-01-19 17:06:01 +01:00
motif: str = assiduite.description if assiduite.description else ""
return render_template(
"assiduites/widgets/assiduite_bubble.j2",
moduleimpl=moduleimpl_infos,
etat=scu.EtatAssiduite(assiduite.etat).name.lower(),
date_debut=assiduite.date_debut.strftime("%d/%m/%Y %H:%M"),
date_fin=assiduite.date_fin.strftime("%d/%m/%Y %H:%M"),
saisie=saisie,
2024-01-19 17:06:01 +01:00
motif=motif,
)