forked from ScoDoc/ScoDoc
3368 lines
115 KiB
Python
3368 lines
115 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
|
|
#
|
|
##############################################################################
|
|
|
|
"""Vues assiduité"""
|
|
|
|
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
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
#
|
|
# 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 = (
|
|
request.url
|
|
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é")
|
|
log(f"redirect_url={redirect_url}")
|
|
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=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 = db.session.get(ModuleImpl, 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=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(etud=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=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_str = request.args.get("annee", "")
|
|
if not annee_str:
|
|
annee = scu.annee_scolaire()
|
|
else:
|
|
try:
|
|
annee = int(annee_str)
|
|
except ValueError as exc:
|
|
raise ScoValueError("année invalide") from exc
|
|
|
|
# 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=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 render_template(
|
|
"sco_page.j2",
|
|
title="Saisie de l'assiduité",
|
|
content="<h3>Aucun étudiant !</h3>",
|
|
)
|
|
|
|
# --- 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.get_evaluation(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(
|
|
formsemestre=evaluation.moduleimpl.formsemestre if evaluation else None
|
|
),
|
|
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ètre annee_sco de la requête GET,
|
|
ou None si absent"""
|
|
annee_sco: str | None = request.args.get("annee_sco", None)
|
|
if annee_sco is None:
|
|
return None
|
|
# Vérification de l'année scolaire
|
|
try:
|
|
annee_sco_int = int(annee_sco)
|
|
except (ValueError, TypeError) as exc:
|
|
raise ScoValueError("Année scolaire invalide")
|
|
|
|
return annee_sco_int
|
|
|
|
|
|
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 = ""
|
|
e = ""
|
|
if obj_type == "assiduite":
|
|
objet: Assiduite = Assiduite.query.get_or_404(obj_id)
|
|
objet_name = scu.EtatAssiduite(objet.etat).version_lisible()
|
|
e = scu.EtatAssiduite(objet.etat).e()
|
|
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é{e}")
|
|
|
|
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 = db.session.get(Justificatif, 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 = db.session.get(User, 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 render_template(
|
|
"sco_page.j2",
|
|
title="Assiduité: feuille saisie hebdomadaire",
|
|
content="<h3>Aucun étudiant !</h3>",
|
|
)
|
|
|
|
# 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=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 render_template(
|
|
"sco_page.j2",
|
|
title="Assiduité: feuille saisie hebdomadaire",
|
|
content="<h3>Aucun étudiant !</h3>",
|
|
)
|
|
|
|
# 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 = db.session.get(FormSemestre, 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,
|
|
)
|