ScoDoc/app/views/assiduites.py
Lyanis Souidi d6d6899a53
Lint
Formatage de tous les fichiers python (sauf migrations) avec black
2024-06-05 10:23:21 +02:00

2856 lines
97 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
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.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,
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
from app.scodoc import sco_etud
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
@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
@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
else:
formsemestre = [sem for sem in sems_etud if sem.est_courant()]
formsemestre = formsemestre[0] if formsemestre else None
# 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"
modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
choices: OrderedDict = OrderedDict()
choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
# indique le nom du semestre dans le menu (optgroup)
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_by_formsemestre[formsemestre.id]
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"
justi: Justificatif = Justificatif.create_justificatif(
etudiant=etud,
date_debut=dt_debut_tz_server,
date_fin=dt_fin_tz_server,
etat=scu.EtatJustificatif.VALIDE,
user_id=current_user.id,
)
# On met à jour les assiduités en fonction du nouveau justificatif
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",
assiuite_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é
"""
# 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 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 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=liste_assi.AssiDisplayOptions(show_module=True),
cache_key=f"tableau-etud-{etud.id}",
)
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)
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()
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
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
@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)
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 _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,
titre="Évènements enregistrés pour cet étudiant",
cache_key: str = "",
force_options: dict[str, object] = None,
) -> tuple[bool, Response | str]:
"""
Prépare un tableau d'assiduités / justificatifs
Cette fonction récupère dans la requête les arguments :
show_pres : bool -> Affiche les présences, par défaut False
show_reta : bool -> Affiche les retard, par défaut False
show_desc : bool -> Affiche les descriptions, par défaut False
Returns:
tuple[bool | Reponse|str ]:
- bool : Vrai si la réponse est du Text/HTML
- Reponse : du Text/HTML ou Une Reponse (téléchargement fichier)
"""
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)
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")
annee_sco: str | None = request.args.get("annee_sco", None)
# Vérification de l'année scolaire
if annee_sco is not None:
try:
annee_sco = int(annee_sco)
except (ValueError, TypeError):
annee_sco = None
# 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,
)
@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":
# Création du justificatif correspondant
justificatif_correspondant: Justificatif = Justificatif.create_justificatif(
etudiant=objet.etudiant,
date_debut=objet.date_debut,
date_fin=objet.date_fin,
etat=scu.EtatJustificatif.VALIDE,
user_id=current_user.id,
)
justificatif_correspondant.justifier_assiduites()
scass.simple_invalidate_cache(
justificatif_correspondant.to_dict(), objet.etudiant.id
)
flash(f"{objet_name} justifiée")
return redirect(request.referrer)
if request.method == "GET":
module: str | int = "" # moduleimpl_id ou chaine libre
if obj_type == "assiduite":
# Construction du menu module
module = _module_selector_multiple(objet.etudiant, objet.moduleimpl_id)
return render_template(
"assiduites/pages/tableau_assiduite_actions.j2",
action=action,
can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView)
or (obj_type == "justificatif" and current_user.id == objet.user_id),
etud=objet.etudiant,
moduleimpl=module,
obj_id=obj_id,
objet_name=objet_name,
objet=_preparer_objet(obj_type, objet),
sco=ScoData(etud=objet.etudiant),
title=f"Assiduité {objet.etudiant.nom_short}",
# type utilisé dans les actions modifier / détails (modifier.j2, details.j2)
type="Justificatif" if obj_type == "justificatif" else "Assiduité",
)
# ----- Cas POST
if obj_type == "assiduite":
try:
_action_modifier_assiduite(objet)
except ScoValueError as error:
raise ScoValueError(error.args[0], request.referrer) from error
flash("L'assiduité a bien été modifiée.")
else:
try:
_action_modifier_justificatif(objet)
except ScoValueError as error:
raise ScoValueError(error.args[0], request.referrer) from error
flash("Le justificatif a bien été modifié.")
return redirect(request.form["table_url"])
def _action_modifier_assiduite(assi: Assiduite):
form = request.form
# Gestion de l'état
etat = scu.EtatAssiduite.get(form["etat"])
if etat is not None:
assi.etat = etat
if etat == scu.EtatAssiduite.PRESENT:
assi.est_just = False
else:
assi.est_just = len(get_assiduites_justif(assi.assiduite_id, False)) > 0
# Gestion de la description
assi.description = form["description"]
possible_moduleimpl_id: str = form["moduleimpl_select"]
# Raise ScoValueError (si None et force module | Etudiant non inscrit | Module non reconnu)
assi.set_moduleimpl(possible_moduleimpl_id)
db.session.add(assi)
db.session.commit()
scass.simple_invalidate_cache(assi.to_dict(True), assi.etudid)
def _action_modifier_justificatif(justi: Justificatif):
"Modifie le justificatif avec les valeurs dans le form"
form = request.form
# Gestion des Dates
date_debut: datetime = scu.is_iso_formated(form["date_debut"], True)
date_fin: datetime = scu.is_iso_formated(form["date_fin"], True)
if date_debut is None or date_fin is None or date_fin < date_debut:
raise ScoValueError("Dates invalides", request.referrer)
justi.date_debut = date_debut
justi.date_fin = date_fin
# Gestion de l'état
etat = scu.EtatJustificatif.get(form["etat"])
if etat is not None:
justi.etat = etat
else:
raise ScoValueError("État invalide", request.referrer)
# Gestion de la raison
justi.raison = form["raison"]
# Gestion des fichiers
files = request.files.getlist("justi_fich")
if len(files) != 0:
files = request.files.values()
archive_name: str = justi.fichier
# Utilisation de l'archiver de justificatifs
archiver: JustificatifArchiver = JustificatifArchiver()
for fich in files:
archive_name, _ = archiver.save_justificatif(
justi.etudiant,
filename=fich.filename,
data=fich.stream.read(),
archive_name=archive_name,
user_id=current_user.id,
)
justi.fichier = archive_name
justi.dejustifier_assiduites()
db.session.add(justi)
db.session.commit()
justi.justifier_assiduites()
scass.simple_invalidate_cache(justi.to_dict(True), justi.etudid)
def _preparer_objet(
obj_type: str, objet: Assiduite | Justificatif, sans_gros_objet: bool = False
) -> dict:
"Préparation d'un objet pour simplifier l'affichage jinja"
objet_prepare: dict = objet.to_dict()
if obj_type == "assiduite":
objet_prepare["etat"] = (
scu.EtatAssiduite(objet.etat).version_lisible().capitalize()
)
objet_prepare["real_etat"] = scu.EtatAssiduite(objet.etat).name.lower()
objet_prepare["description"] = (
"" if objet.description is None else objet.description
)
objet_prepare["description"] = objet_prepare["description"].strip()
# Gestion du module
objet_prepare["module"] = objet.get_module(True)
# Gestion justification
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("/signal_assiduites_diff")
@scodoc
@permission_required(Permission.AbsChange)
def signal_assiduites_diff():
"""
Utilisé notamment par "Saisie différée" sur tableau de bord semetstre"
Arguments de la requête:
- group_ids : liste des groupes
example : group_ids=1,2,3
- formsemestre_id : id du formsemestre
example : formsemestre_id=1
- moduleimpl_id : id du moduleimpl
example : moduleimpl_id=1
(Permet de pré-générer une plage. Si non renseigné, la plage sera vide)
(Les trois valeurs suivantes doivent être renseignées ensemble)
- date
example : date=01/01/2021
- heure_debut
example : heure_debut=08:00
- heure_fin
example : heure_fin=10:00
Exemple de requête :
signal_assiduites_diff?formsemestre_id=67&group_ids=400&moduleimpl_id=1229&date=15/04/2024&heure_debut=12:34&heure_fin=12:55
"""
# Récupération des paramètres de la requête
group_ids: list[int] = request.args.get("group_ids", None)
formsemestre_id: int = request.args.get("formsemestre_id", -1)
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
etudiants: list[Identite] = []
# Vérification des groupes
if group_ids is None:
group_ids = []
else:
group_ids = group_ids.split(",")
map(str, group_ids)
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
)
if not groups_infos.members:
return (
html_sco_header.sco_header(page_title="Assiduité: saisie différée")
+ "<h3>Aucun étudiant ! </h3>"
+ html_sco_header.sco_footer()
)
# Récupération des étudiants
etudiants.extend(
[Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members]
)
etudiants = list(sorted(etudiants, key=lambda etud: etud.sort_key))
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>"
)
# Pré-remplissage des sélecteurs
moduleimpl_id = request.args.get("moduleimpl_id", -1)
try:
moduleimpl_id = int(moduleimpl_id)
except ValueError:
moduleimpl_id = -1
# date fra (dd/mm/yyyy)
date = request.args.get("date", "")
# heures (hh:mm)
heure_deb = request.args.get("heure_debut", "")
heure_fin = request.args.get("heure_fin", "")
# vérifications des sélecteurs
date = date if re.match(r"^\d{2}\/\d{2}\/\d{4}$", date) else ""
heure_deb = heure_deb if re.match(r"^[0-2]\d:[0-5]\d$", heure_deb) else ""
heure_fin = heure_fin if re.match(r"^[0-2]\d:[0-5]\d$", heure_fin) else ""
nouv_plage: list[str] = [date, heure_deb, heure_fin]
return render_template(
"assiduites/pages/signal_assiduites_diff.j2",
etudiants=etudiants,
moduleimpl_select=_module_selector(
formsemestre=formsemestre, moduleimpl_id=moduleimpl_id
),
gr=gr_tit,
nonworkdays=_non_work_days(),
sco=ScoData(formsemestre=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,
),
nouv_plage=nouv_plage,
formsemestre_id=formsemestre_id,
group_ids=group_ids,
)
@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.AbsJustifView)
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)
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,
)
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,
)
@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()
readonly: bool = not current_user.has_permission(Permission.AbsChange)
form: EditAssiForm = EditAssiForm(request.form)
if readonly:
form.disable_all()
# peuplement moduleimpl_select
modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
choices: OrderedDict = OrderedDict()
choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
# indique le nom du semestre dans le menu (optgroup)
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_by_formsemestre[formsemestre.id]
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=True,
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,
)
# --- Fonctions internes ---
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
) -> str:
"""menu HTML <select> pour choix moduleimpl
Prend les semestres de l'année, sauf si only_form est indiqué.
"""
modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
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,
)
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,
)