ScoDoc/app/views/assiduites.py

3358 lines
114 KiB
Python

# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# 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
#
##############################################################################
import datetime
import json
import re
from collections import OrderedDict
from flask import g, request, render_template, flash
from flask import abort, url_for, redirect, Response, session
from flask_login import current_user
from flask_sqlalchemy.query import Query
from markupsafe import Markup
from werkzeug.exceptions import HTTPException
from app import db, log
from app.api import tools
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
scodoc,
permission_required,
)
from app.forms.assiduite.ajout_assiduite_etud import (
AjoutAssiOrJustForm,
AjoutAssiduiteEtudForm,
AjoutJustificatifEtudForm,
)
from app.forms.assiduite.edit_assiduite_etud import EditAssiForm
from app.models import (
Assiduite,
Departement,
Evaluation,
FormSemestre,
GroupDescr,
Identite,
Justificatif,
Module,
ModuleImpl,
ScoDocSiteConfig,
Scolog,
)
from app.scodoc.codes_cursus import UE_STANDARD
from app.auth.models import User
from app.models.assiduites import get_assiduites_justif, is_period_conflicting
from app.tables.list_etuds import RowEtud, TableEtud
import app.tables.liste_assiduites as liste_assi
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, sco_groups
from app.scodoc import sco_etud
from app.scodoc import sco_excel
from app.scodoc import sco_find_etud
from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_gen_cal
from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
# --------------------------------------------------------------------
#
# Assiduité (/ScoDoc/<dept>/Scolarite/Assiduites/...)
#
# --------------------------------------------------------------------
@bp.route("/")
@bp.route("/bilan_dept")
@scodoc
@scass.check_disabled
@permission_required(Permission.AbsChange)
def bilan_dept():
"""Gestionnaire assiduités, page principale"""
# Gestion des billets d'absences
if current_user.has_permission(
Permission.AbsChange
) and sco_preferences.get_preference("handle_billets_abs"):
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>
"""
else:
billets = ""
# Récupération du département
dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first()
# Récupération d'un formsemestre
# (pour n'afficher que les justificatifs liés au formsemestre)
formsemestre_id = request.args.get("formsemestre_id", "")
formsemestre = None
if formsemestre_id:
try:
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
except AttributeError:
formsemestre_id = ""
# <=> 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]
),
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]
# Récupération des formsemestres (pour le menu déroulant)
formsemestres: Query = FormSemestre.get_dept_formsemestres_courants(dept)
formsemestres_choices: dict[int, str] = {
fs.id: fs.titre_annee() for fs in formsemestres
}
# 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),
formsemestres=formsemestres_choices,
formsemestre_id=None if not formsemestre else formsemestre.id,
)
@bp.route("/ajout_assiduite_etud", methods=["GET", "POST"])
@scodoc
@scass.check_disabled
@permission_required(Permission.AbsChange)
def ajout_assiduite_etud() -> str | Response:
"""
ajout_assiduite_etud Saisie d'une assiduité d'un étudiant
Args:
etudid (int): l'identifiant de l'étudiant
date_deb, date_fin: heures début et fin (ISO sans timezone)
moduleimpl_id
evaluation_id : si présent, mode "évaluation"
fmt: si xls, renvoie le tableau des assiduités enregistrées
Returns:
str: l'html généré
"""
etudid: int = request.args.get("etudid", -1)
etud = Identite.get_etud(etudid)
formsemestre_id = request.args.get("formsemestre_id", None)
# Gestion du semestre
formsemestre: FormSemestre | None = None
sems_etud: list[FormSemestre] = etud.get_formsemestres()
if formsemestre_id:
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
formsemestre = formsemestre if formsemestre in sems_etud else None
if formsemestre is None:
raise ScoValueError("Etudiant non inscrit dans ce semestre")
else:
formsemestre = list(
sorted(sems_etud, key=lambda x: x.est_courant(), reverse=True)
) # Mets le semestre courant en premier et les autres dans l'ordre
formsemestre = formsemestre[0] if formsemestre else None
if formsemestre is None:
raise ScoValueError(
"L'étudiant n'est actuellement pas inscrit: on ne peut pas saisir son assiduité"
)
# Gestion évaluations (appel à la page depuis les évaluations)
evaluation_id: int | None = request.args.get("evaluation_id")
saisie_eval = evaluation_id is not None
moduleimpl_id: int | None = request.args.get("moduleimpl_id", "")
redirect_url: str = (
"#"
if not saisie_eval
else url_for(
"notes.evaluation_check_absences_html",
evaluation_id=evaluation_id,
scodoc_dept=g.scodoc_dept,
)
)
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"
choices: OrderedDict = OrderedDict()
choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
# Récupération des modulesimpl du semestre si existant.
if formsemestre:
# indique le nom du semestre dans le menu (optgroup)
modimpls_from_formsemestre = etud.get_modimpls_from_formsemestre(formsemestre)
group_name: str = formsemestre.titre_annee()
choices[group_name] = [
(m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
for m in modimpls_from_formsemestre
if m.module.ue.type == UE_STANDARD
]
choices.move_to_end("", last=False)
form.modimpl.choices = choices
force_options: dict = None
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(redirect_url)
ok = _record_assiduite_etud(etud, form, formsemestre=formsemestre)
if ok:
flash("enregistré")
return redirect(redirect_url)
force_options = {"show_pres": True, "show_reta": True}
# Le tableau des assiduités+justificatifs déjà en base:
is_html, tableau = _prepare_tableau(
liste_assi.AssiJustifData.from_etudiants(
etud,
),
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}",
force_options=force_options,
)
if not is_html:
return tableau
return render_template(
"assiduites/pages/ajout_assiduite_etud.j2",
etud=etud,
form=form,
moduleimpl_id=moduleimpl_id,
redirect_url=redirect_url,
sco=ScoData(etud, formsemestre=formsemestre),
tableau=tableau,
scu=scu,
)
def _get_dates_from_assi_form(
form: AjoutAssiOrJustForm,
etud: Identite,
from_justif: bool = False,
formsemestre: FormSemestre | None = None,
) -> tuple[
bool, datetime.datetime | None, datetime.datetime | None, datetime.datetime | None
]:
"""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.
"""
debut_jour = ScoDocSiteConfig.get("assi_morning_time", "08:00")
fin_jour = ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
date_fin = None
# On commence par convertir individuellement tous les champs
try:
date_debut = datetime.datetime.strptime(form.date_debut.data, scu.DATE_FMT)
except ValueError:
date_debut = None
form.set_error("date début invalide", form.date_debut)
try:
date_fin = (
datetime.datetime.strptime(form.date_fin.data, scu.DATE_FMT)
if form.date_fin.data
else None
)
except ValueError:
date_fin = None
form.set_error("date fin invalide", form.date_fin)
if not from_justif and date_fin:
# Ne prends pas en compte les heures pour les assiduités sur plusieurs jours
heure_debut = datetime.time.fromisoformat(debut_jour)
heure_fin = datetime.time.fromisoformat(fin_jour)
else:
try:
heure_debut = datetime.time.fromisoformat(
form.heure_debut.data or debut_jour
)
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"
)
try:
heure_fin = datetime.time.fromisoformat(form.heure_fin.data or fin_jour)
except ValueError:
form.set_error("heure fin invalide", form.heure_fin)
if not form.ok:
return False, None, None, None
# 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, scu.DATE_FMT)
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)
# Ajoute time zone serveur
dt_debut_tz_server = scu.TIME_ZONE.localize(dt_debut)
dt_fin_tz_server = scu.TIME_ZONE.localize(dt_fin)
if from_justif:
cas: list[bool] = [
# cas 1 (date de fin vide et pas d'heure de début)
not form.date_fin.data and not form.heure_debut.data,
# cas 2 (date de fin et pas d'heures)
form.date_fin.data != "" and not form.heure_debut.data,
]
if any(cas):
dt_debut_tz_server = dt_debut_tz_server.replace(hour=0, minute=0)
dt_fin_tz_server = dt_fin_tz_server.replace(hour=23, minute=59)
# Vérification dates contenu dans un semestre de l'étudiant
dates_semestres: list[tuple[datetime.date, datetime.date]] = (
[(sem.date_debut, sem.date_fin) for sem in etud.get_formsemestres()]
if formsemestre is None
else [(formsemestre.date_debut, formsemestre.date_fin)]
)
# Vérification date début
if not any(
[
dt_debut_tz_server.date() >= deb and dt_debut_tz_server.date() <= fin
for deb, fin in dates_semestres
]
):
form.set_error(
(
"La date de début n'appartient à aucun semestre de l'étudiant"
if formsemestre is None
else "La date de début n'appartient pas au semestre"
),
form.date_debut,
)
# Vérification date fin
if form.date_fin.data and not any(
[
dt_fin_tz_server.date() >= deb and dt_fin_tz_server.date() <= fin
for deb, fin in dates_semestres
]
):
form.set_error(
(
"La date de fin n'appartient à aucun semestre de l'étudiant"
if not formsemestre
else "La date de fin n'appartient pas au semestre"
),
form.date_fin,
)
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
def _record_assiduite_etud(
etud: Identite,
form: AjoutAssiduiteEtudForm,
formsemestre: FormSemestre | None = None,
) -> 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, etud, formsemestre=formsemestre)
# 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
if not ok:
return False
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,
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"
# L'état est Valide si l'user à la permission JustifValidate
etat: scu.EtatJustificatif = scu.EtatJustificatif.ATTENTE
if current_user.has_permission(Permission.JustifValidate):
etat = scu.EtatJustificatif.VALIDE
else:
flash(
"Vous ne pouvez pas créer de justificatif valide,"
+ " il est automatiquement passé 'EN ATTENTE'",
)
justi: Justificatif = Justificatif.create_justificatif(
etudiant=etud,
date_debut=dt_debut_tz_server,
date_fin=dt_fin_tz_server,
etat=etat,
user_id=current_user.id,
)
# On met à jour les assiduités en fonction du nouveau justificatif
justi.justifier_assiduites()
# Invalider cache
scass.simple_invalidate_cache(ass.to_dict(), etud.id)
return True
except ScoValueError as exc:
err: str = f"Erreur: {exc.args[0]}"
if (
exc.args[0]
== "Duplication: la période rentre en conflit avec une plage enregistrée"
):
# Récupération de la première assiduité conflictuelle
conflits: Query = etud.assiduites.filter(
Assiduite.date_debut < dt_fin_tz_server,
Assiduite.date_fin > dt_debut_tz_server,
)
assi: Assiduite = conflits.first()
lien: str = url_for(
"assiduites.edit_assiduite_etud",
assiduite_id=assi.assiduite_id,
scodoc_dept=g.scodoc_dept,
)
form.set_error(
Markup(
err
+ f' <a href="{lien}" target="_blank" title="Voir le détail de'
+ " l'assiduité conflictuelle\">(conflit)</a>"
)
)
else:
form.set_error(err)
return False
@bp.route("/bilan_etud")
@scodoc
@permission_required(Permission.ScoView)
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é
"""
# Initialisation des options du tableau
options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions(
show_module=True
)
# 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")
# Gestion du filtre de module
moduleimpl_id = request.args.get("moduleimpl_id", None)
if moduleimpl_id is not None:
try:
moduleimpl_id = int(moduleimpl_id)
except ValueError:
moduleimpl_id = None
options.moduleimpl_id = moduleimpl_id
# Gestion des dates du bilan (par défaut l'année scolaire)
date_debut = scu.date_debut_annee_scolaire().strftime(scu.DATE_FMT)
date_fin: str = scu.date_fin_annee_scolaire().strftime(scu.DATE_FMT)
# Récupération de la métrique d'assiduité
assi_metric = scu.translate_assiduites_metric(
sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id),
)
# préparation du selecteur de moduleimpl
annee_sco = _get_anne_sco_from_request()
moduleimpl_select: str = _module_selector_multiple(
etud, moduleimpl_id, no_default=True, annee_sco=annee_sco
)
# Préparation de la page
tableau = _prepare_tableau(
liste_assi.AssiJustifData.from_etudiants(etud),
filename=f"assiduites-justificatifs-{etud.id}",
afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=0),
options=options,
cache_key=f"tableau-etud-{etud.id}",
moduleimpl_select=moduleimpl_select,
)
if not tableau[0]:
return tableau[1]
# Génération de la page HTML
return render_template(
"assiduites/pages/bilan_etud.j2",
assi_metric=assi_metric,
assi_seuil=_get_seuil(),
date_debut=date_debut,
date_fin=date_fin,
sco=ScoData(etud),
tableau=tableau[1],
)
@bp.route("/edit_justificatif_etud/<int:justif_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
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é
"""
try:
justif = Justificatif.get_justificatif(justif_id)
except HTTPException:
flash("Justificatif invalide")
return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept))
readonly = not current_user.has_permission(Permission.AbsChange)
form = AjoutJustificatifEtudForm(obj=justif)
# Limite les choix d'état si l'utilisateur n'a pas la permission de valider
choix_etat: list = [
(scu.EtatJustificatif.ATTENTE.value, "En attente de validation")
]
if current_user.has_permission(Permission.JustifValidate):
choix_etat = [
("", "Choisir..."),
(scu.EtatJustificatif.ATTENTE.value, "En attente de validation"),
(scu.EtatJustificatif.NON_VALIDE.value, "Non valide"),
(scu.EtatJustificatif.MODIFIE.value, "Modifié"),
(scu.EtatJustificatif.VALIDE.value, "Valide"),
]
form.etat.choices = choix_etat
if readonly:
form.disable_all()
# Set the default value for the etat field
if request.method == "GET":
form.date_debut.data = justif.date_debut.strftime(scu.DATE_FMT)
form.date_fin.data = justif.date_fin.strftime(scu.DATE_FMT)
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(scu.TIME_FMT)
form.heure_fin.data = justif.date_fin.strftime(scu.TIME_FMT)
form.entry_date.data = (
justif.entry_date.strftime(scu.DATE_FMT) if justif.entry_date else ""
)
form.entry_time.data = (
justif.entry_date.strftime(scu.TIME_FMT) if justif.entry_date else ""
)
form.etat.data = str(justif.etat)
back_url = request.args.get("back_url", None)
redirect_url = back_url or url_for(
"assiduites.bilan_etud",
scodoc_dept=g.scodoc_dept,
etudid=justif.etudiant.id,
)
if form.validate_on_submit():
if form.cancel.data or not current_user.has_permission(
Permission.AbsChange
): # cancel button
return redirect(redirect_url)
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",
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=_preparer_objet("justificatif", 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,
readonly=not current_user.has_permission(Permission.AbsChange),
)
@bp.route("/ajout_justificatif_etud", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def ajout_justificatif_etud():
"""
ajout_justificatif_etud : Affichage et création des justificatifs de l'étudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
etud = Identite.get_etud(request.args.get("etudid"))
redirect_url = url_for(
"assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
)
form = AjoutJustificatifEtudForm()
# Limite les choix d'état si l'utilisateur n'a pas la permission de valider
choix_etat: list = [
(scu.EtatJustificatif.ATTENTE.value, "En attente de validation")
]
if current_user.has_permission(Permission.JustifValidate):
choix_etat = [
("", "Choisir..."),
(scu.EtatJustificatif.ATTENTE.value, "En attente de validation"),
(scu.EtatJustificatif.NON_VALIDE.value, "Non valide"),
(scu.EtatJustificatif.MODIFIE.value, "Modifié"),
(scu.EtatJustificatif.VALIDE.value, "Valide"),
]
form.etat.choices = choix_etat
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(
etud,
),
filename=f"justificatifs-{etud.nom or ''}",
afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=2),
options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True),
afficher_options=False,
titre="Justificatifs enregistrés pour cet étudiant",
cache_key=f"tableau-etud-{etud.id}",
)
if not is_html:
return tableau
return render_template(
"assiduites/pages/ajout_justificatif_etud.j2",
etud=etud,
form=form,
title=f"Ajout justificatif absence pour {etud.html_link_fiche()}",
redirect_url=redirect_url,
sco=ScoData(etud),
scu=scu,
tableau=tableau,
)
def _record_justificatif_etud(
etud: Identite, form: AjoutJustificatifEtudForm, justif: Justificatif | None = None
) -> 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
"""
(
ok,
dt_debut_tz_server,
dt_fin_tz_server,
dt_entry_date_tz_server,
) = _get_dates_from_assi_form(form, etud, from_justif=True)
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")
return False
if (
not current_user.has_permission(Permission.JustifValidate)
and etat != scu.EtatJustificatif.ATTENTE
):
log("_record_justificatif_etud: pas la permission")
form.set_error(
"Erreur: vous n'avez pas la permission de définir la validité d'un justificatif"
)
return False
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
justif.dejustifier_assiduites()
if justif.edit_from_form(form):
message = "Justificatif modifié"
# On met à jour la db pour avoir les bonnes donnés pour le journal etud
db.session.add(justif)
db.session.commit()
Scolog.logdb(
method="edit_justificatif",
etudid=etud.id,
msg=f"justificatif modif: {justif}",
)
else:
message = "Pas de modification"
fichier_suppr: list[str] = request.form.getlist("suppr_fichier_just")
if len(fichier_suppr) > 0 and justif.fichier is not None:
archiver: JustificatifArchiver = JustificatifArchiver()
for fichier in fichier_suppr:
archiver.delete_justificatif(etud, justif.fichier, fichier)
flash(f"Fichier {fichier} supprimé")
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")
db.session.rollback()
return False
db.session.commit()
justif.justifier_assiduites()
scass.simple_invalidate_cache(justif.to_dict(), etud.id)
flash(message)
return True
except ScoValueError as exc:
log(f"_record_justificatif_etud: erreur {exc.args[0]}")
db.session.rollback()
form.set_error(f"Erreur: {exc.args[0]}")
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()
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]}")
return False
@bp.route("/calendrier_assi_etud")
@scodoc
@permission_required(Permission.ScoView)
def calendrier_assi_etud():
"""
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é
"""
# 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()))
# Récupération des années d'étude de l'étudiant
annees: list[int] = []
for ins in etud.formsemestre_inscriptions:
date_deb = ins.formsemestre.date_debut
date_fin = ins.formsemestre.date_fin
annees.extend(
[
scu.annee_scolaire_repr(date_deb.year, date_deb.month),
scu.annee_scolaire_repr(date_fin.year, date_fin.month),
]
)
annees = sorted(annees, reverse=True)
# Transformation en une liste "json"
# (sera utilisé pour générer le selecteur d'année)
annees_str: str = json.dumps(annees)
cal = CalendrierAssi(
annee,
etud,
mode_demi=mode_demi,
show_pres=show_pres,
show_reta=show_reta,
)
calendrier: str = cal.get_html()
# 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("/signal_assiduites_group")
@scodoc
@scass.check_disabled
@permission_required(Permission.AbsChange)
def signal_assiduites_group():
"""
signal_assiduites_group Saisie des assiduités des groupes pour le jour donné
Returns:
str: l'html généré
"""
# Récupération des paramètres de l'url
# formsemestre_id est optionnel si modimpl est indiqué
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
date: str = request.args.get("day", datetime.date.today().isoformat())
heures: list[str] = [
request.args.get("heure_deb", ""),
request.args.get("heure_fin", ""),
]
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)
# Vérification du moduleimpl_id
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError):
moduleimpl_id = None
if moduleimpl_id is not None and moduleimpl_id >= 0:
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
else:
modimpl = None
# 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
# Gestion des groupes
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids,
moduleimpl_id=moduleimpl_id,
formsemestre_id=formsemestre_id,
select_all_when_unspecified=True,
)
if not groups_infos.members:
return (
html_sco_header.sco_header(page_title="Saisie de l'assiduité")
+ "<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")
# Récupération des étudiants des groupes
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
flash(
"La date sélectionnée n'est pas dans le semestre. Choisissez une autre date."
)
return sco_gen_cal.calendrier_choix_date(
formsemestre.date_debut,
formsemestre.date_fin,
url=url_for(
"assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
group_ids=",".join(group_ids),
moduleimpl_id=moduleimpl_id,
day="placeholder",
),
mode="jour",
titre="Choix de la date",
)
# --- 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
# Récupération du nom des/du groupe(s)
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>"
)
# 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",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
),
non_present=sco_preferences.get_preference(
"non_present",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
),
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 de l'assiduité",
)
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,
*args,
**kwargs,
):
super().__init__(table, etud, *args, **kwargs)
self.etat_assiduite = etat_assiduite
self.est_just = est_just
# remplace lien vers fiche par lien vers calendrier
self.target_url = url_for(
"assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
)
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"]
@bp.route("/etat_abs_date")
@scodoc
@permission_required(Permission.ScoView)
def etat_abs_date():
"""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)
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)
"""
# 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")
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
)
# 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
# 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
etuds = [
etud for gr_etuds in [group.etuds for group in groups] for etud in gr_etuds
]
# Récupération des assiduites des étudiants
assiduites: Assiduite = Assiduite.query.filter(
Assiduite.etudid.in_([etud.id for etud in etuds])
)
# Filtrage des assiduités en fonction des dates données
assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin)
# Génération table
table = TableEtud(row_class=RowEtudWithAssi)
for etud in sorted(etuds, key=lambda e: e.sort_key):
# On récupère l'état de la première assiduité sur la période
assi = assiduites.filter_by(etudid=etud.id).first()
etat = ""
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)
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,
)
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,
)
@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.
"""
# 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)
# 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)
# Vérification de la désactivation de l'assiduité
if err_msg := scass.has_assiduites_disable_pref(formsemestre):
raise ScoValueError(err_msg, request.referrer)
etuds = etuds_sorted_from_ids([m["etudid"] for m in groups_infos.members])
# 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"),
)
# 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,
)
# récupération du/des noms du/des groupes
if groups_infos.tous_les_etuds_du_sem:
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>"
)
# Génération de la page
return render_template(
"assiduites/pages/visu_assi_group.j2",
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,
),
date_debut=_dateiso_to_datefr(dates["debut"]),
date_fin=_dateiso_to_datefr(dates["fin"]),
gr_tit=gr_tit,
group_ids=request.args.get("group_ids", None),
sco=ScoData(formsemestre=groups_infos.get_formsemestre()),
tableau=table.html(),
title=f"Assiduité {grp} {groups_infos.groups_titles}",
)
def _get_anne_sco_from_request() -> int | None:
"""La valeur du paramètreannee_sco de la requête GET,
ou None si absent"""
annee_sco: str | None = request.args.get("annee_sco", None)
# Vérification de l'année scolaire
if annee_sco is not None:
try:
return int(annee_sco)
except (ValueError, TypeError):
pass
return None
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,
afficher_options: bool = True,
moduleimpl_select: str = None,
titre="Évènements enregistrés pour cet étudiant",
cache_key: str = "",
force_options: dict[str, object] = None,
annee_sco: int | None = None,
) -> tuple[bool, Response | str]:
"""
Prépare un tableau d'assiduités / justificatifs
Cette fonction récupère dans la requête les arguments :
annee_sco : int -> XXX
n_page : int -> XXX
page_number : int -> XXX
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)
"""
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)
annee_sco = _get_anne_sco_from_request() if annee_sco is None else annee_sco
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
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,
annee_sco=annee_sco,
)
if force_options is not None:
options.remplacer(**force_options)
table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
table_data=data,
options=options,
filtre=filtre,
no_pagination=fmt.startswith("xls"),
titre=cache_key,
)
if fmt.startswith("xls"):
return False, scu.send_file(
table.excel(),
filename=filename,
mime=scu.XLSX_MIMETYPE,
suffix=scu.XLSX_SUFFIX,
)
return True, render_template(
"assiduites/widgets/tableau.j2",
table=table,
total_pages=table.total_pages,
options=options,
afficher_options=afficher_options,
titre=titre,
moduleimpl_select=moduleimpl_select,
)
@bp.route("/recup_assiduites_plage", methods=["POST"])
@scodoc
@permission_required(Permission.AbsChange)
def recup_assiduites_plage():
"""
Renvoie un fichier excel contenant toutes les assiduités d'une plage
La plage est définie par les valeurs "datedeb" et "datefin" du formulaire
Par défaut tous les étudiants du département sont concernés
Si le champs "formsemestre_id" est présent dans le formulaire et est non vide,
seuls les étudiants inscrits dans ce semestre sont concernés.
"""
date_deb: datetime.datetime = request.form.get("datedeb")
date_fin: datetime.datetime = request.form.get("datefin")
# Vérification des dates
try:
date_deb = datetime.datetime.strptime(date_deb, scu.DATE_FMT)
except ValueError as exc:
raise ScoValueError("date_debut invalide", dest_url=request.referrer) from exc
try:
date_fin = datetime.datetime.strptime(date_fin, scu.DATE_FMT)
except ValueError as exc:
raise ScoValueError("date_fin invalide", dest_url=request.referrer) from exc
# Récupération des étudiants
etuds: Query = []
formsemestre_id: str | None = request.form.get("formsemestre_id")
name: str = ""
if formsemestre_id is not None and formsemestre_id != "":
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
etuds = formsemestre.etuds
name = formsemestre.session_id()
else:
dept: Departement = Departement.query.get_or_404(g.scodoc_dept_id)
etuds = dept.etudiants
name = dept.acronym
# Récupération des assiduités/justificatifs
etudids: list[int] = [etud.id for etud in etuds]
assiduites: Query = Assiduite.query.filter(Assiduite.etudid.in_(etudids))
justificatifs: Query = Justificatif.query.filter(Justificatif.etudid.in_(etudids))
# Filtrage des assiduités/justificatifs en fonction des dates données
assiduites = scass.filter_by_date(assiduites, Assiduite, date_deb, date_fin)
justificatifs = scass.filter_by_date(
justificatifs, Justificatif, date_deb, date_fin
)
table_data: liste_assi.AssiJustifData = liste_assi.AssiJustifData(
assiduites_query=assiduites,
justificatifs_query=justificatifs,
)
options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions(
show_pres=True,
show_reta=True,
show_module=True,
show_desc=True,
show_etu=True,
annee_sco=-1,
)
date_deb_str: str = date_deb.strftime("%d-%m-%Y")
date_fin_str: str = date_fin.strftime("%d-%m-%Y")
filename: str = f"assiduites_{name}_{date_deb_str}_{date_fin_str}"
tableau: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
table_data,
options=options,
titre="tableau-dept-" + filename,
no_pagination=True,
)
return scu.send_file(
tableau.excel(),
filename=filename,
mime=scu.XLSX_MIMETYPE,
suffix=scu.XLSX_SUFFIX,
)
@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":
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":
# L'état est Valide si l'user à la permission JustifValidate
etat: scu.EtatJustificatif = scu.EtatJustificatif.ATTENTE
if current_user.has_permission(Permission.JustifValidate):
etat = scu.EtatJustificatif.VALIDE
else:
flash(
"Vous ne pouvez pas créer de justificatif valide,"
+ " il est automatiquement passé 'EN ATTENTE'",
)
# 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=etat,
user_id=current_user.id,
)
justificatif_correspondant.justifier_assiduites()
scass.simple_invalidate_cache(
justificatif_correspondant.to_dict(), objet.etudiant.id
)
if etat == scu.EtatJustificatif.VALIDE:
flash(f"{objet_name} justifiée")
return redirect(request.referrer)
# Si on arrive ici, c'est que l'action n'est pas autorisée
# cette fonction ne sert plus qu'à supprimer ou justifier
flash("Méthode non autorisée", "error")
return redirect(request.referrer)
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
objet_prepare["justification"] = {
"est_just": objet.est_just,
"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[Assiduite] = justif.get_assiduites()
for assi in assiduites:
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(scu.DATEATIME_FMT)
objet_prepare["real_date_fin"] = objet.date_fin.isoformat()
objet_prepare["date_debut"] = objet.date_debut.strftime(scu.DATEATIME_FMT)
objet_prepare["real_date_debut"] = objet.date_debut.isoformat()
objet_prepare["entry_date"] = objet.entry_date.strftime(scu.DATEATIME_FMT)
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("/signale_evaluation_abs/<int:evaluation_id>/<int:etudid>")
@scodoc
@permission_required(Permission.AbsChange)
def signale_evaluation_abs(etudid: int = None, evaluation_id: int = None):
"""
Signale l'absence d'un étudiant à une évaluation
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
# 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(
"assiduites.ajout_assiduite_etud",
etudid=etudid,
evaluation_id=evaluation.id,
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,
)
)
# 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,
)
except ScoValueError as exc:
# En cas d'erreur
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
saisie de l'assiduité de l'étudiant."""
dest: str = url_for(
"assiduites.ajout_assiduite_etud",
etudid=etudid,
evaluation_id=evaluation.id,
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",
)
raise ScoValueError(msg, dest) from exc
if assiduite_unique is not None:
db.session.add(assiduite_unique)
db.session.commit()
# 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,
)
)
@bp.route("traitement_justificatifs")
@scodoc
@permission_required(Permission.JustifValidate)
@permission_required(Permission.AbsJustifView)
@scass.check_disabled
def traitement_justificatifs():
"""Page de traitement des justificatifs
On traite les justificatifs par formsemestre
On peut Valider, Invalider ou mettre en ATT
"""
# Récupération du formsemestre
formsemestre_id: int = request.args.get("formsemestre_id", -1)
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
lignes: list[dict] = []
# Récupération des justificatifs
justificatifs_query: Query = scass.filter_by_formsemestre(
Justificatif.query, Justificatif, formsemestre
)
justificatifs_query = justificatifs_query.filter(
Justificatif.etat.in_(
[scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE]
)
).order_by(Justificatif.date_debut)
justif: Justificatif
for justif in justificatifs_query:
etud: Identite = justif.etudiant
assi_stats: tuple[int, int, int] = scass.get_assiduites_count(
etud.id, formsemestre.to_dict()
)
etud_dict: dict = {
"id": etud.id,
"nom": etud.nom,
"prenom": etud.prenom,
"nomprenom": etud.nomprenom,
"stats": assi_stats,
"sort_key": etud.sort_key,
}
assiduites_justifiees: list[Assiduite] = justif.get_assiduites().all()
# fichiers justificatifs archivés:
filenames, nb_files = justif.get_fichiers()
fichiers = {
"total": nb_files,
"filenames": filenames,
}
lignes.append(
{
"etud": etud_dict,
"justif": justif,
"assiduites": assiduites_justifiees,
"fichiers": fichiers,
"etat": scu.EtatJustificatif(justif.etat).name.lower(),
}
)
# Tri en fonction du nom des étudiants
lignes = sorted(lignes, key=lambda x: x["etud"]["sort_key"])
return render_template(
"assiduites/pages/traitement_justificatifs.j2",
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
lignes=lignes,
)
@bp.route("signal_assiduites_hebdo")
@scodoc
@permission_required(Permission.ScoView)
@scass.check_disabled
def signal_assiduites_hebdo():
"""
signal_assiduites_hebdo
paramètres obligatoires :
- formsemestre_id : id du formsemestre
- groups_id : id des groupes (séparés par des virgules -> 1,2,3)
paramètres optionnels :
- week : date semaine (iso 8601 -> 20XX-WXX), par défaut la semaine actuelle
- moduleimpl_id : id du moduleimpl (par défaut None)
Permissions :
- ScoView -> page en lecture seule
- AbsChange -> page en lecture/écriture
"""
# Récupération des paramètres
moduleimpl_id: int = request.args.get("moduleimpl_id", None)
group_ids: str = request.args.get("group_ids", "") # ex: "1,2,3"
formsemestre_id: int = request.args.get("formsemestre_id", -1)
week: str = request.args.get("week", datetime.datetime.now().strftime("%G-W%V"))
# Vérification des paramètres
if group_ids == "" or formsemestre_id == -1:
raise ScoValueError("Paramètres manquants", dest_url=request.referrer)
# Récupération du moduleimpl
try:
moduleimpl_id: int = int(moduleimpl_id)
except (ValueError, TypeError):
moduleimpl_id: str | None = None if moduleimpl_id != "autre" else moduleimpl_id
# Récupération du formsemestre
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Vérification semaine dans format iso 8601 et formsemestre
regex_iso8601 = r"^\d{4}-W\d{2}$"
if week and not re.match(regex_iso8601, week):
raise ScoValueError("Semaine invalide", dest_url=request.referrer)
fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W")
fs_fin_iso8601 = formsemestre.date_fin.strftime("%Y-W%W")
# Utilisation de la propriété de la norme iso 8601
# les chaines sont triables par ordre alphanumérique croissant
# et produiront le même ordre que les dates par ordre chronologique croissant
if (not week) or week < fs_deb_iso8601 or week > fs_fin_iso8601:
if week:
flash(
"""La semaine n'est pas dans le semestre,
choisissez la semaine sur laquelle saisir l'assiduité"""
)
return sco_gen_cal.calendrier_choix_date(
date_debut=formsemestre.date_debut,
date_fin=formsemestre.date_fin,
url=url_for(
"assiduites.signal_assiduites_hebdo",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
group_ids=group_ids,
moduleimpl_id=moduleimpl_id,
week="placeholder",
),
mode="semaine",
titre="Choix de la semaine",
)
# Vérification des groupes
group_ids = group_ids.split(",") if group_ids != "" else []
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 hebdomadaire")
+ "<h3>Aucun étudiant ! </h3>"
+ html_sco_header.sco_footer()
)
# Récupération des étudiants
etudiants: list[Identite] = [
Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members
]
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>"
)
# Gestion des jours
jours: dict[str, list[str]] = {
"lun": [
"Lundi",
datetime.datetime.strptime(week + "-1", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"mar": [
"Mardi",
datetime.datetime.strptime(week + "-2", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"mer": [
"Mercredi",
datetime.datetime.strptime(week + "-3", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"jeu": [
"Jeudi",
datetime.datetime.strptime(week + "-4", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"ven": [
"Vendredi",
datetime.datetime.strptime(week + "-5", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"sam": [
"Samedi",
datetime.datetime.strptime(week + "-6", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"dim": [
"Dimanche",
datetime.datetime.strptime(week + "-7", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
}
non_travail = sco_preferences.get_preference("non_travail")
non_travail = non_travail.replace(" ", "").split(",")
hebdo_jours: list[tuple[bool, str]] = []
for key, val in jours.items():
hebdo_jours.append((key in non_travail, val))
url_choix_semaine = url_for(
"assiduites.signal_assiduites_hebdo",
group_ids=",".join(map(str, groups_infos.group_ids)),
week="",
scodoc_dept=g.scodoc_dept,
formsemestre_id=groups_infos.formsemestre_id,
moduleimpl_id=moduleimpl_id,
)
erreurs: list = session.pop("feuille_abs_hebdo-erreurs", [])
return render_template(
"assiduites/pages/signal_assiduites_hebdo.j2",
title="Assiduité: saisie hebdomadaire",
gr=gr_tit,
etudiants=etudiants,
moduleimpl_select=_module_selector(
formsemestre=formsemestre, moduleimpl_id=moduleimpl_id
),
hebdo_jours=hebdo_jours,
readonly=not current_user.has_permission(Permission.AbsChange),
non_present=sco_preferences.get_preference(
"non_present",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
),
url_choix_semaine=url_choix_semaine,
query_string=request.query_string.decode(encoding="utf-8"),
erreurs=erreurs,
)
@bp.route("edit_assiduite_etud/<int:assiduite_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
def edit_assiduite_etud(assiduite_id: int):
"""
Page affichant les détails d'une assiduité
Si le current_user alors la page propose un formulaire de modification
"""
try:
assi: Assiduite = Assiduite.get_assiduite(assiduite_id=assiduite_id)
except HTTPException:
flash("Assiduité invalide")
return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept))
etud: Identite = assi.etudiant
formsemestre: FormSemestre = assi.get_formsemestre()
# Vérification de la désactivation de l'assiduité
if err_msg := scass.has_assiduites_disable_pref(formsemestre):
raise ScoValueError(err_msg, request.referrer)
readonly: bool = not current_user.has_permission(Permission.AbsChange)
form: EditAssiForm = EditAssiForm(request.form)
if readonly:
form.disable_all()
# peuplement moduleimpl_select
choices: OrderedDict = OrderedDict()
choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
# Récupération des modulesimpl du semestre si existant.
if formsemestre:
# indique le nom du semestre dans le menu (optgroup)
modimpls_from_formsemestre = etud.get_modimpls_from_formsemestre(formsemestre)
group_name: str = formsemestre.titre_annee()
choices[group_name] = [
(m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
for m in modimpls_from_formsemestre
if m.module.ue.type == UE_STANDARD
]
choices.move_to_end("", last=False)
form.modimpl.choices = choices
# Vérification formulaire
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(request.referrer)
# vérification des valeurs
# Gestion de l'état
etat = form.assi_etat.data
try:
etat = int(etat)
etat = scu.EtatAssiduite.inverse().get(etat, None)
except ValueError:
etat = None
if etat is None:
form.error_messages.append("État invalide")
form.ok = False
description = form.description.data or ""
description = description.strip()
moduleimpl_id = form.modimpl.data if form.modimpl.data is not None else -1
# Vérifications des dates / horaires
ok, dt_deb, dt_fin, dt_entry = _get_dates_from_assi_form(
form, etud, from_justif=True, formsemestre=formsemestre
)
if ok:
if is_period_conflicting(
dt_deb, dt_fin, etud.assiduites, Assiduite, assi.id
):
form.set_error("La période est en conflit avec une autre assiduité")
form.ok = False
if form.ok:
assi.etat = etat
assi.description = description
if moduleimpl_id != -1:
assi.set_moduleimpl(moduleimpl_id)
assi.date_debut = dt_deb
assi.date_fin = dt_fin
assi.entry_date = dt_entry
db.session.add(assi)
db.session.commit()
scass.simple_invalidate_cache(assi.to_dict(format_api=True), assi.etudid)
flash("enregistré")
return redirect(request.referrer)
# Remplissage du formulaire
form.assi_etat.data = str(assi.etat)
form.description.data = assi.description
moduleimpl_id: int | str | None = assi.get_moduleimpl_id() or ""
form.modimpl.data = str(moduleimpl_id)
form.date_debut.data = assi.date_debut.strftime(scu.DATE_FMT)
form.heure_debut.data = assi.date_debut.strftime(scu.TIME_FMT)
form.date_fin.data = assi.date_fin.strftime(scu.DATE_FMT)
form.heure_fin.data = assi.date_fin.strftime(scu.TIME_FMT)
form.entry_date.data = assi.entry_date.strftime(scu.DATE_FMT)
form.entry_time.data = assi.entry_date.strftime(scu.TIME_FMT)
return render_template(
"assiduites/pages/edit_assiduite_etud.j2",
etud=etud,
sco=ScoData(etud, formsemestre=formsemestre),
form=form,
readonly=readonly,
objet=_preparer_objet("assiduite", assi),
title=f"Assiduité {etud.nom_short}",
)
def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
"""Génère la liste des assiduités d'un étudiant pour le bulletin mail"""
# 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),
)
# 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
)
)
# 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())
# On calcule les statistiques
stats: dict = scass.get_assiduites_stats(
assiduites, metric=metrique, filtered={"split": True}
)
# On sépare :
# - 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,
)
@bp.route("feuille_abs_hebdo", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
@scass.check_disabled
def feuille_abs_hebdo():
"""
GET : Renvoie un tableau excel pour permettre la saisie des absences
POST: Enregistre les absences saisies et renvoie sur la page de saisie hebdomadaire
Affiche un choix de semaine si "week" n'est pas renseigné
Si POST:
renvoie sur la page saisie_assiduites_hebdo
"""
# Récupération des groupes
group_ids: str = request.args.get("group_ids", "")
if group_ids == "":
raise ScoValueError("Paramètre 'group_ids' manquant", dest_url=request.referrer)
# Vérification du semestre
formsemestre_id: int = request.args.get("formsemestre_id", -1)
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Vériication de la semaine
week: str = request.args.get("week", datetime.datetime.now().strftime("%G-W%V"))
regex_iso8601 = r"^\d{4}-W\d{2}$"
if week and not re.match(regex_iso8601, week):
raise ScoValueError("Semaine invalide", dest_url=request.referrer)
fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W")
fs_fin_iso8601 = formsemestre.date_fin.strftime("%Y-W%W")
# Utilisation de la propriété de la norme iso 8601
# les chaines sont triables par ordre alphanumérique croissant
# et produiront le même ordre que les dates par ordre chronologique croissant
if (not week) or week < fs_deb_iso8601 or week > fs_fin_iso8601:
if week:
flash(
"""La semaine n'est pas dans le semestre,
choisissez la semaine sur laquelle saisir l'assiduité"""
)
return sco_gen_cal.calendrier_choix_date(
date_debut=formsemestre.date_debut,
date_fin=formsemestre.date_fin,
url=url_for(
"assiduites.feuilles_abs_hebdo",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
group_ids=group_ids,
week="placeholder",
),
mode="semaine",
titre="Choix de la semaine",
)
# Vérification des groupes
group_ids = group_ids.split(",") if group_ids != "" else []
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é: feuille saisie hebdomadaire"
)
+ "<h3>Aucun étudiant ! </h3>"
+ html_sco_header.sco_footer()
)
# Gestion des jours
jours: dict[str, list[str]] = {
"lun": [
"Lundi",
datetime.datetime.strptime(week + "-1", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"mar": [
"Mardi",
datetime.datetime.strptime(week + "-2", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"mer": [
"Mercredi",
datetime.datetime.strptime(week + "-3", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"jeu": [
"Jeudi",
datetime.datetime.strptime(week + "-4", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"ven": [
"Vendredi",
datetime.datetime.strptime(week + "-5", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"sam": [
"Samedi",
datetime.datetime.strptime(week + "-6", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
"dim": [
"Dimanche",
datetime.datetime.strptime(week + "-7", "%G-W%V-%u").strftime("%d/%m/%Y"),
],
}
non_travail = sco_preferences.get_preference("non_travail")
non_travail = non_travail.replace(" ", "").split(",")
hebdo_jours: list[tuple[bool, str]] = []
for key, val in jours.items():
hebdo_jours.append((key in non_travail, val))
if request.method == "POST":
url_saisie: str = url_for(
"assiduites.signal_assiduites_hebdo",
scodoc_dept=g.scodoc_dept,
**request.args,
)
# Vérification du fichier
file = request.files.get("file", None)
if file is None or file.filename == "":
flash("Erreur : Pas de fichier")
return redirect(url_saisie)
# Vérification des heures
heures: list[str] = request.form.get("heures", "").split(",")
if len(heures) != 4:
flash("Erreur : Les heures sont incorrectes")
return redirect(url_saisie)
# Récupération du moduleimpl
moduleimpl_id = request.form.get("moduleimpl_id")
# Enregistrement des assiduites
erreurs = _import_feuille_abs_hebdo(
file,
heures=["08:15", "13:00", "13:00", "18:15"],
moduleimpl_id=moduleimpl_id,
)
if erreurs:
session["feuille_abs_hebdo-erreurs"] = erreurs
return redirect(url_saisie)
filename = f"feuille_signal_abs_{week}"
xls = _excel_feuille_abs(
formsemestre=formsemestre, groups_infos=groups_infos, hebdo_jours=hebdo_jours
)
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
@bp.route("feuille_abs_formsemestre", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
@scass.check_disabled
def feuille_abs_formsemestre():
"""
Permet l'importation d'une liste d'assiduités depuis un fichier excel
GET:
Affiche un formulaire pour l'import et les erreurs lors de l'import
POST:
Nécessite un fichier excel contenant une liste d'assiduités (file)
"""
# Récupération du formsemestre
formsemestre_id: int = request.args.get("formsemestre_id", -1)
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
erreurs: list = []
if request.method == "POST":
# Récupération et vérification du fichier
file = request.files.get("file")
if file is None or file.filename == "":
raise ScoValueError("Pas de fichier", dest_url=request.referrer)
# Récupération du type d'identifiant
type_identifiant: str = request.form.get("type_identifiant", "etudid")
# Importation du fichier
erreurs = _import_excel_assiduites_list(
file, formsemestre=formsemestre, type_etud_identifiant=type_identifiant
)
if erreurs:
flash("Erreurs lors de l'importation, voir bas de page", "error")
else:
flash("Importation réussie")
return render_template(
"assiduites/pages/feuille_abs_formsemestre.j2",
erreurs=erreurs,
titre_form=formsemestre.titre_annee(),
sco=ScoData(formsemestre=formsemestre),
)
# --- Fonctions internes ---
def _import_feuille_abs_hebdo(
file, heures: list[str], moduleimpl_id: int = None
) -> list:
"""
Importe un fichier excel au format de la feuille d'absence hebdomadaire
(voir _excel_feuille_abs)
Génère les assiduités correspondantes et retourne une liste d'erreurs
Les erreurs sont sous la forme :
(message, [num_ligne, ...contenu_ligne])
Attention : num_ligne correspond au numéro de la ligne dans le fichier excel
"""
data: list = sco_excel.excel_file_to_list(file)
erreurs: list = []
# Récupération des jours (entête du tableau)
jours: list[str] = [
str_jour.split(" ")[1] for str_jour in data[1][4][4:] if str_jour
]
lettres: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# On récupère uniquement les lignes d'étudiants (on ignore les headers)
data: list = data[1][6:]
# Chaque ligne commence par [!etudid][nom][prenom][groupe]
for num, ligne in enumerate(data):
etudid = ligne[0].replace("!", "") # on enlève le point d'exclamation
try:
etud = Identite.get_etud(etudid)
except HTTPException as exc:
erreurs.append((exc.description, [num + 6, "A"] + ligne))
continue
for i, etat in enumerate(ligne[4:]):
try:
etat: str = etat.strip().upper()
if etat:
# Vérification de l'état
if etat not in ["ABS", "RET", "PRE"]:
raise ScoValueError(f"État invalide => {etat}")
etat: scu.EtatAssiduite = {
"ABS": scu.EtatAssiduite.ABSENT,
"RET": scu.EtatAssiduite.RETARD,
"PRE": scu.EtatAssiduite.PRESENT,
}.get(etat, scu.EtatAssiduite.ABSENT)
else:
continue
# Génération des dates de début et de fin de l'assiduité
heure_debut: str = heures[0] if i % 2 == 0 else heures[2]
heure_fin: str = heures[1] if i % 2 == 0 else heures[3]
try:
date_debut: datetime.datetime = datetime.datetime.strptime(
jours[i // 2] + " " + heure_debut, "%d/%m/%Y %H:%M"
)
date_fin: datetime.datetime = datetime.datetime.strptime(
jours[i // 2] + " " + heure_fin, "%d/%m/%Y %H:%M"
)
except ValueError as exc:
raise ScoValueError("Dates Invalides") from exc
# On met les dates à la timezone du serveur
date_debut = scu.TIME_ZONE.localize(date_debut)
date_fin = scu.TIME_ZONE.localize(date_fin)
# Création de l'assiduité
assiduite: Assiduite = Assiduite.create_assiduite(
etud=etud,
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
user_id=current_user.id,
)
if moduleimpl_id:
assiduite.set_moduleimpl(moduleimpl_id)
db.session.add(assiduite)
scass.simple_invalidate_cache(assiduite.to_dict())
except HTTPException as exc:
erreurs.append((exc.description, [num + 6, lettres[i + 4]] + ligne))
except ScoValueError as exc:
erreurs.append((exc.args[0], [num + 6, lettres[i + 4]] + ligne))
continue
# On commit les changements
db.session.commit()
return erreurs
def _import_excel_assiduites_list(
file, formsemestre: FormSemestre, type_etud_identifiant: str = "etudid"
) -> list:
"""
Importe un fichier excel contenant une liste d'assiduités
sous le format :
| etudid/nip/ine | date_debut | date_fin | etat [optionnel -> "ABS"] | Module [optionnel -> None] |
Génère les assiduités correspondantes et retourne une liste d'erreurs
Les erreurs sont sous la forme :
(message, [num_ligne, ...contenu_ligne])
Attention : num_ligne correspond au numéro de la ligne dans le fichier excel
"""
# On récupère les données du fichier
data: list = sco_excel.excel_file_to_list(file)
# On récupère le deuxième élément de la liste (les lignes du tableur)
# Le premier element de la liste correspond à la description des feuilles excel
data: list = data[1]
# On parcourt les lignes et on les traite
erreurs: list[tuple[str, list]] = []
for num, ligne in enumerate(data):
identifiant_etud = ligne[0] # etudid/nip/ine
date_debut_str = ligne[1] # iso / fra / excel
date_fin_str = ligne[2] # iso / fra / excel
etat = ligne[3].strip().upper() # etat abs par défaut, sinon RET ou PRE
etat = etat or "ABS"
module = ligne[4] or None # code du module
moduleimpl: ModuleImpl | None = None
try:
# On récupère l'étudiant
etud: Identite = _find_etud(identifiant_etud, type_etud_identifiant)
# On vérifie que l'étudiant appartient au semestre
if formsemestre not in etud.get_formsemestres():
raise ScoValueError("Étudiant non inscrit dans le semestre")
# On transforme les dates
date_debut: datetime.datetime = _try_parse_date(date_debut_str)
date_fin: datetime.datetime = _try_parse_date(date_fin_str)
# On met les dates à la timezone du serveur
date_debut = scu.TIME_ZONE.localize(date_debut)
date_fin = scu.TIME_ZONE.localize(date_fin)
# Vérification de l'état
if etat not in ["ABS", "RET", "PRE"]:
raise ScoValueError(f"État invalide => {etat}")
etat: scu.EtatAssiduite = {
"ABS": scu.EtatAssiduite.ABSENT,
"RET": scu.EtatAssiduite.RETARD,
"PRE": scu.EtatAssiduite.PRESENT,
}.get(etat, scu.EtatAssiduite.ABSENT)
# On récupère le moduleimpl à partir du code du module et du formsemestre
if module:
moduleimpl = _get_moduleimpl_from_code(module, formsemestre)
assiduite: Assiduite = Assiduite.create_assiduite(
etud=etud,
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
moduleimpl=moduleimpl,
)
db.session.add(assiduite)
scass.simple_invalidate_cache(assiduite.to_dict())
except ScoValueError as exc:
erreurs.append((exc.args[0], [num + 1] + ligne))
except HTTPException as exc:
erreurs.append((exc.description, [num + 1] + ligne))
db.session.commit()
return erreurs
def _get_moduleimpl_from_code(
module_code: str, formsemestre: FormSemestre
) -> ModuleImpl:
query: Query = ModuleImpl.query.filter(
ModuleImpl.module.has(Module.code == module_code),
ModuleImpl.formsemestre_id == formsemestre.id,
)
moduleimpl: ModuleImpl = query.first()
if moduleimpl is None:
raise ScoValueError("Module non trouvé")
return moduleimpl
def _try_parse_date(date: str) -> datetime.datetime:
"""
Tente de parser une date sous différents formats
renvoie la première date valide
"""
# On tente de parser la date en iso (yyyy-mm-ddThh:mm:ss)
try:
return datetime.datetime.fromisoformat(date)
except ValueError:
pass
# On tente de parser la date en français (dd/mm/yyyy hh:mm:ss)
try:
return datetime.datetime.strptime(date, "%d/%m/%Y %H:%M:%S")
except ValueError:
pass
raise ScoValueError("Date invalide")
def _find_etud(identifiant: str, type_identifiant: str) -> Identite:
"""
Renvoie l'étudiant correspondant à l'identifiant
"""
if type_identifiant == "etudid":
return tools.get_etud(etudid=identifiant)
elif type_identifiant == "nip":
return tools.get_etud(nip=identifiant)
elif type_identifiant == "ine":
return tools.get_etud(ine=identifiant)
else:
raise ScoValueError("Type d'identifiant invalide")
def _excel_feuille_abs(
formsemestre: FormSemestre,
groups_infos: sco_groups_view.DisplayedGroupsInfos,
hebdo_jours: list[tuple[bool, str]],
):
"""
Génère un fichier excel pour la saisie des absences hebdomadaires
Colonnes :
- A : [formsemestre_id][etudid...]
- B : [nom][nom...]
- C : [prenom][prenom...]
- D : [groupes][groupe...]
- 2 colonnes (matin/aprem) par jour de la semaine
"""
ws = sco_excel.ScoExcelSheet("Saisie_ABS")
# == Préparation des données ==
lines: list[tuple] = []
for membre in groups_infos.members:
etudid = membre["etudid"]
groups = sco_groups.get_etud_groups(etudid, formsemestre_id=formsemestre.id)
grc = sco_groups.listgroups_abbrev(groups)
line = [
str(etudid),
membre["nom"].upper(),
membre["prenom"].lower().capitalize(),
membre["etat"],
grc,
]
line += ["" for _ in range(len(hebdo_jours) * 2)]
lines.append(line)
# == Préparation du fichier ==
# colonnes
lettres: str = "EFGHIJKLMNOPQRSTUVWXYZ"
# ajuste largeurs colonnes (unite inconnue, empirique)
ws.set_column_dimension_width("A", 11.0 / 7) # codes
# ws.set_column_dimension_hidden("A", True) # codes
ws.set_column_dimension_width("B", 164.00 / 7) # noms
ws.set_column_dimension_width("C", 109.0 / 7) # prenoms
ws.set_column_dimension_width("D", 164.0 / 7) # groupes
i: int = 0
for jour in hebdo_jours:
ws.set_column_dimension_width(lettres[i], 100.0 / 7)
ws.set_column_dimension_width(lettres[i + 1], 100.0 / 7)
if jour[0]:
ws.set_column_dimension_hidden(lettres[i], True)
ws.set_column_dimension_hidden(lettres[i + 1], True)
i += 2
# fontes
font_base = sco_excel.Font(name="Arial", size=12)
font_bold = sco_excel.Font(name="Arial", bold=True)
font_italic = sco_excel.Font(
name="Arial", size=12, italic=True, color=sco_excel.COLORS.RED.value
)
font_titre = sco_excel.Font(name="Arial", bold=True, size=14)
font_purple = sco_excel.Font(name="Arial", color=sco_excel.COLORS.PURPLE.value)
font_brown = sco_excel.Font(name="Arial", color=sco_excel.COLORS.BROWN.value)
# bordures
side_thin = sco_excel.Side(border_style="thin", color=sco_excel.COLORS.BLACK.value)
border_top = sco_excel.Border(top=side_thin)
border_right = sco_excel.Border(right=side_thin)
border_sides = sco_excel.Border(left=side_thin, right=side_thin, bottom=side_thin)
# fonds
fill_light_yellow = sco_excel.PatternFill(
patternType="solid", fgColor=sco_excel.COLORS.LIGHT_YELLOW.value
)
# styles
style_titres = {"font": font_titre}
style_expl = {"font": font_italic}
style_ro = { # cells read-only
"font": font_purple,
"border": border_right,
}
style_dem = {
"font": font_brown,
"border": border_top,
}
style_nom = { # style pour nom, prenom, groupe
"font": font_base,
"border": border_top,
}
style_abs = {
"font": font_bold,
"fill": fill_light_yellow,
"border": border_sides,
}
# filtre
filter_top = 6
filter_bottom = filter_top + len(lines)
filter_left = "A"
filter_right = lettres[len(hebdo_jours) * 2 - 1]
ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")
# == Ecritures statiques ==
ws.append_single_cell_row(
"Saisir les assiduités dans les cases jaunes (ABS, RET, PRE)", style=style_expl
)
ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl)
# Nom du semestre
ws.append_single_cell_row(
scu.unescape_html(formsemestre.titre_annee()), style_titres
)
# ligne blanche
ws.append_blank_row()
# == Ecritures dynamiques ==
# Ecriture des entêtes
row = [ws.make_cell("", style=style_titres) for _ in range(4)]
for jour in hebdo_jours:
row.append(ws.make_cell(" ".join(jour[1]), style=style_titres))
row.append(ws.make_cell("", style=style_titres))
ws.append_row(row)
row = [
ws.make_cell(f"!{formsemestre.id}", style=style_ro),
ws.make_cell("Nom", style=style_titres),
ws.make_cell("Prénom", style=style_titres),
ws.make_cell("Groupe", style=style_titres),
]
for jour in hebdo_jours:
row.append(ws.make_cell("Matin", style=style_titres))
row.append(ws.make_cell("Après-Midi", style=style_titres))
ws.append_row(row)
# Ecriture des données
for line in lines:
st = style_nom
if line[3] != "I":
st = style_dem
if line[3] == "D": # demissionnaire
s = "DEM"
else:
s = line[3] # etat autre
else:
s = line[4] # groupes TD/TP/...
ws.append_row(
[
ws.make_cell("!" + line[0], style_ro), # code
ws.make_cell(line[1], st),
ws.make_cell(line[2], st),
ws.make_cell(s, st),
]
+ [ws.make_cell(" ", style_abs) for _ in range(len(hebdo_jours) * 2)]
)
# ligne blanche
ws.append_blank_row()
return ws.generate()
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:
"""
_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(scu.TIME_FMT)]
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")}'
def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> str:
"""
_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
"""
# récupération des ues du semestre
ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
modimpls_list: list[dict] = ntc.get_modimpls_dict()
# 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"
modules: list[dict[str, str | int]] = []
# Récupération de l'id et d'un nom lisible pour chaque moduleimpl
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,
)
def _module_selector_multiple(
etud: Identite,
moduleimpl_id: int = None,
only_form: FormSemestre = None,
no_default: bool = False,
annee_sco: int | None = None,
) -> str:
"""menu HTML <select> pour choix moduleimpl
Prend les semestres de l'année indiquée, sauf si only_form est indiqué.
"""
modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(
scu.annee_scolaire() if annee_sco is None else annee_sco
)
choices = OrderedDict()
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)
return render_template(
"assiduites/widgets/moduleimpl_selector_multiple.j2",
choices=choices,
moduleimpl_id=moduleimpl_id,
no_default=no_default,
)
def _timeline(heures=None) -> str:
"""
_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
"""
return render_template(
"assiduites/widgets/timeline.j2",
t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"),
t_mid=ScoDocSiteConfig.assi_get_rounded_time("assi_lunch_time", "13:00:00"),
t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"),
tick_time=ScoDocSiteConfig.get("assi_tick_time", 15),
heures=heures,
)
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(
"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."""
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 ---
class JourAssi(sco_gen_cal.Jour):
"""
Représente un jour d'assiduité
"""
def __init__(
self,
date: datetime.date,
assiduites: Query,
justificatifs: Query,
parent: "CalendrierAssi",
):
super().__init__(date)
# assiduités et justificatifs du jour
self.assiduites = assiduites
self.justificatifs = justificatifs
self.parent = parent
def get_html(self) -> str:
# si non travaillé on renvoie une case vide
if self.is_non_work():
return ""
html: str = (
self._get_html_demi() if self.parent.mode_demi else self._get_html_normal()
)
html = f'<div class="assi_case">{html}</div>'
if self.has_assiduite():
minitimeline: str = f"""
<div class="dayline">
<div class="dayline-title">
<span>{self.get_date()}</span>
{self._generate_minitimeline()}
</div>
</div>
"""
html += minitimeline
return html
def has_assiduite(self) -> bool:
"""Renvoie True si le jour a une assiduité"""
return self.assiduites.count() > 0
def _get_html_normal(self) -> str:
"""
Renvoie l'html de la case du calendrier
(version journee normale (donc une couleur))
"""
class_name = self._get_color_normal()
return f'<span class="{class_name}"></span>'
def _get_html_demi(self) -> str:
"""
Renvoie l'html de la case du calendrier
(version journee divisée en demi-journées (donc 2 couleurs))
"""
matin = self._get_color_demi(True)
aprem = self._get_color_demi(False)
return f'<span class="{matin}"></span><span class="{aprem}"></span>'
def _get_color_normal(self) -> str:
"""renvoie la classe css correspondant
à la case du calendrier
(version journee normale)
"""
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=self.parent.show_pres,
show_reta=self.parent.show_reta,
)
est_just = self._get_color_justificatifs_cascade(
self._get_etats_from_justificatifs(self.justificatifs),
)
return f"color {etat} {est_just}"
def _get_color_demi(self, matin: bool) -> str:
"""renvoie la classe css correspondant
à la case du calendrier
(version journee divisée en demi-journees)
"""
heure_midi = scass.str_to_time(ScoDocSiteConfig.get("assi_lunch_time", "13:00"))
plage: tuple[datetime.datetime, datetime.datetime] = ()
if matin:
heure_matin = scass.str_to_time(
ScoDocSiteConfig.get("assi_morning_time", "08:00")
)
plage = (
# date debut
scu.localize_datetime(
datetime.datetime.combine(self.date, heure_matin)
),
# date fin
scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
)
else:
heure_soir = scass.str_to_time(
ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
)
# séparation en demi journées
plage = (
# 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 = [
assi
for assi in self.assiduites
if scu.is_period_overlapping(
(assi.date_debut, assi.date_fin), plage, bornes=False
)
]
justificatifs = [
justi
for justi in self.justificatifs
if scu.is_period_overlapping(
(justi.date_debut, justi.date_fin), plage, bornes=False
)
]
etat = self._get_color_assiduites_cascade(
self._get_etats_from_assiduites(assiduites),
show_pres=self.parent.show_pres,
show_reta=self.parent.show_reta,
)
est_just = self._get_color_justificatifs_cascade(
self._get_etats_from_justificatifs(justificatifs),
)
if est_just == "est_just" and any(
not assi.est_just
for assi in assiduites
if assi.etat != scu.EtatAssiduite.PRESENT
):
est_just = ""
return f"color {etat} {est_just}"
def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]:
return list(set(scu.EtatAssiduite(assi.etat) for assi in assiduites))
def _get_etats_from_justificatifs(
self, justificatifs: Query
) -> list[scu.EtatJustificatif]:
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 _generate_minitimeline(self) -> str:
"""
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
# 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 = max(((deb - heure_matin) / longueur_timeline) * 100, 0)
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,
)
class CalendrierAssi(sco_gen_cal.Calendrier):
"""
Représente un calendrier d'assiduité d'un étudiant
"""
def __init__(self, annee: int, etudiant: Identite, **options):
# 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)
super().__init__(date_debut, date_fin)
# On récupère les assiduités et les justificatifs
self.etud_assiduites: Query = scass.filter_by_date(
etudiant.assiduites,
Assiduite,
date_deb=date_debut,
date_fin=date_fin,
)
self.etud_justificatifs: Query = scass.filter_by_date(
etudiant.justificatifs,
Justificatif,
date_deb=date_debut,
date_fin=date_fin,
)
# Ajout des options (exemple : mode_demi, show_pres, show_reta, ...)
for key, value in options.items():
setattr(self, key, value)
def instanciate_jour(self, date: datetime.date) -> JourAssi:
"""
Instancie un jour d'assiduité
"""
assiduites: Query = scass.filter_by_date(
self.etud_assiduites,
Assiduite,
date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
)
justificatifs: Query = scass.filter_by_date(
self.etud_justificatifs,
Justificatif,
date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
)
return JourAssi(date, assiduites, justificatifs, parent=self)
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()
motif: str = assiduite.description or "Non spécifié"
# Récupérer date
if assiduite.date_debut.date() == assiduite.date_fin.date():
jour = assiduite.date_debut.strftime("%d/%m/%Y")
heure_deb: str = assiduite.date_debut.strftime("%H:%M")
heure_fin: str = assiduite.date_fin.strftime("%H:%M")
date: str = f"{jour} de {heure_deb} à {heure_fin}"
else:
date: str = (
f"du {assiduite.date_debut.strftime('%d/%m/%Y')} "
+ f"au {assiduite.date_fin.strftime('%d/%m/%Y')}"
)
return render_template(
"assiduites/widgets/assiduite_bubble.j2",
moduleimpl=moduleimpl_infos,
etat=scu.EtatAssiduite(assiduite.etat).name.lower(),
date=date,
saisie=saisie,
motif=motif,
)