# -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Emmanuel Viennet emmanuel.viennet@viennet.net # module codé par Matthias Hartmann, 2023 # ############################################################################## import datetime import json import re from collections import OrderedDict from flask import g, request, render_template, flash from flask import abort, url_for, redirect, Response from flask_login import current_user from flask_sqlalchemy.query import Query from markupsafe import Markup from werkzeug.exceptions import HTTPException from app import db, log from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.decorators import ( scodoc, permission_required, ) from app.forms.assiduite.ajout_assiduite_etud import ( AjoutAssiOrJustForm, AjoutAssiduiteEtudForm, AjoutJustificatifEtudForm, ) from app.forms.assiduite.edit_assiduite_etud import EditAssiForm from app.models import ( Assiduite, Departement, Evaluation, FormSemestre, GroupDescr, Identite, Justificatif, ModuleImpl, ScoDocSiteConfig, Scolog, ) from app.scodoc.codes_cursus import UE_STANDARD from app.auth.models import User from app.models.assiduites import get_assiduites_justif, is_period_conflicting from app.tables.list_etuds import RowEtud, TableEtud import app.tables.liste_assiduites as liste_assi from app.views import assiduites_bp as bp from app.views import ScoData # --------------- from app.scodoc.sco_permissions import Permission from app.scodoc import html_sco_header from app.scodoc import sco_moduleimpl from app.scodoc import sco_preferences from app.scodoc import sco_groups_view from app.scodoc import sco_etud from app.scodoc import sco_find_etud from app.scodoc import sco_assiduites as scass from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_gen_cal from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids from app.scodoc.sco_archives_justificatifs import JustificatifArchiver CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS # -------------------------------------------------------------------- # # Assiduité (/ScoDoc//Scolarite/Assiduites/...) # # -------------------------------------------------------------------- @bp.route("/") @bp.route("/bilan_dept") @scodoc @permission_required(Permission.AbsChange) def bilan_dept(): """Gestionnaire assiduités, page principale""" # Gestion des billets d'absences if current_user.has_permission( Permission.AbsChange ) and sco_preferences.get_preference("handle_billets_abs"): billets = f"""

Billets d'absence

""" else: billets = "" # Récupération du département dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first() # Récupération d'un formsemestre # (pour n'afficher que les justificatifs liés au formsemestre) formsemestre_id = request.args.get("formsemestre_id", "") formsemestre = None if formsemestre_id: try: formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) except AttributeError: formsemestre_id = "" # <=> Génération du tableau <=> # Récupération des étudiants du département / groupe etudids: list[int] = [etud.id for etud in dept.etudiants] # cas département group_ids = request.args.get("group_ids", "") if group_ids and formsemestre: groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids.split(","), formsemestre_id=formsemestre.id, select_all_when_unspecified=True, ) if groups_infos.members: etudids = [m["etudid"] for m in groups_infos.members] # justificatifs (en attente ou modifiés avec les semestres associés) justificatifs_query: Query = Justificatif.query.filter( Justificatif.etat.in_( [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE] ), Justificatif.etudid.in_(etudids), ) # Filtrage par semestre si formsemestre_id != "" if formsemestre: justificatifs_query = justificatifs_query.filter( Justificatif.date_debut >= formsemestre.date_debut, Justificatif.date_debut <= formsemestre.date_fin, ) data = liste_assi.AssiJustifData( assiduites_query=None, justificatifs_query=justificatifs_query, ) fname: str = "Bilan Département" cache_key: str = "tableau-dept" titre: str = "Justificatifs en attente ou modifiés" if formsemestre: fname += f" {formsemestre.titre_annee()}" cache_key += f"-{formsemestre.id}" titre += f" {formsemestre.titre_annee()}" if group_ids: cache_key += f" {group_ids}" table = _prepare_tableau( data, afficher_etu=True, filename=fname, titre=titre, cache_key=cache_key, ) if not table[0]: return table[1] # Récupération des formsemestres (pour le menu déroulant) formsemestres: Query = FormSemestre.get_dept_formsemestres_courants(dept) formsemestres_choices: dict[int, str] = { fs.id: fs.titre_annee() for fs in formsemestres } # Peuplement du template jinja return render_template( "assiduites/pages/bilan_dept.j2", tableau=table[1], search_etud=sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"), billets=billets, sco=ScoData(formsemestre=formsemestre), formsemestres=formsemestres_choices, formsemestre_id=None if not formsemestre else formsemestre.id, ) @bp.route("/ajout_assiduite_etud", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def ajout_assiduite_etud() -> str | Response: """ ajout_assiduite_etud Saisie d'une assiduité d'un étudiant Args: etudid (int): l'identifiant de l'étudiant date_deb, date_fin: heures début et fin (ISO sans timezone) moduleimpl_id evaluation_id : si présent, mode "évaluation" fmt: si xls, renvoie le tableau des assiduités enregistrées Returns: str: l'html généré """ etudid: int = request.args.get("etudid", -1) etud = Identite.get_etud(etudid) formsemestre_id = request.args.get("formsemestre_id", None) # Gestion du semestre formsemestre: FormSemestre | None = None sems_etud: list[FormSemestre] = etud.get_formsemestres() if formsemestre_id: formsemestre = FormSemestre.get_formsemestre(formsemestre_id) formsemestre = formsemestre if formsemestre in sems_etud else None if formsemestre is None: raise ScoValueError("Etudiant non inscrit dans ce semestre") else: formsemestre = list( sorted(sems_etud, key=lambda x: x.est_courant(), reverse=True) ) # Mets le semestre courant en premier et les autres dans l'ordre formsemestre = formsemestre[0] if formsemestre else None if formsemestre is None: raise ScoValueError( "L'étudiant n'est actuellement pas inscrit: on ne peut pas saisir son assiduité" ) # Gestion évaluations (appel à la page depuis les évaluations) evaluation_id: int | None = request.args.get("evaluation_id") saisie_eval = evaluation_id is not None moduleimpl_id: int | None = request.args.get("moduleimpl_id", "") redirect_url: str = ( "#" if not saisie_eval else url_for( "notes.evaluation_check_absences_html", evaluation_id=evaluation_id, scodoc_dept=g.scodoc_dept, ) ) form = AjoutAssiduiteEtudForm(request.form) # On dresse la liste des modules de l'année scolaire en cours # auxquels est inscrit l'étudiant pour peupler le menu "module" choices: OrderedDict = OrderedDict() choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")] # Récupération des modulesimpl du semestre si existant. if formsemestre: # indique le nom du semestre dans le menu (optgroup) modimpls_from_formsemestre = etud.get_modimpls_from_formsemestre(formsemestre) group_name: str = formsemestre.titre_annee() choices[group_name] = [ (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}") for m in modimpls_from_formsemestre if m.module.ue.type == UE_STANDARD ] choices.move_to_end("", last=False) form.modimpl.choices = choices force_options: dict = None if form.validate_on_submit(): if form.cancel.data: # cancel button return redirect(redirect_url) ok = _record_assiduite_etud(etud, form, formsemestre=formsemestre) if ok: flash("enregistré") return redirect(redirect_url) force_options = {"show_pres": True, "show_reta": True} # Le tableau des assiduités+justificatifs déjà en base: is_html, tableau = _prepare_tableau( liste_assi.AssiJustifData.from_etudiants( etud, ), filename=f"assiduite-{etud.nom or ''}", afficher_etu=False, filtre=liste_assi.AssiFiltre(type_obj=1), options=liste_assi.AssiDisplayOptions(show_module=True), cache_key=f"tableau-etud-{etud.id}", force_options=force_options, ) if not is_html: return tableau return render_template( "assiduites/pages/ajout_assiduite_etud.j2", etud=etud, form=form, moduleimpl_id=moduleimpl_id, redirect_url=redirect_url, sco=ScoData(etud, formsemestre=formsemestre), tableau=tableau, scu=scu, ) def _get_dates_from_assi_form( form: AjoutAssiOrJustForm, etud: Identite, from_justif: bool = False, formsemestre: FormSemestre | None = None, ) -> tuple[ bool, datetime.datetime | None, datetime.datetime | None, datetime.datetime | None ]: """Prend les dates et heures du form, les vérifie puis converti en deux datetime, en timezone du serveur. Ramène ok=True si ok. Met des messages d'erreur dans le form. """ debut_jour = ScoDocSiteConfig.get("assi_morning_time", "08:00") fin_jour = ScoDocSiteConfig.get("assi_afternoon_time", "17:00") date_fin = None # On commence par convertir individuellement tous les champs try: date_debut = datetime.datetime.strptime(form.date_debut.data, scu.DATE_FMT) except ValueError: date_debut = None form.set_error("date début invalide", form.date_debut) try: date_fin = ( datetime.datetime.strptime(form.date_fin.data, scu.DATE_FMT) if form.date_fin.data else None ) except ValueError: date_fin = None form.set_error("date fin invalide", form.date_fin) if not from_justif and date_fin: # Ne prends pas en compte les heures pour les assiduités sur plusieurs jours heure_debut = datetime.time.fromisoformat(debut_jour) heure_fin = datetime.time.fromisoformat(fin_jour) else: try: heure_debut = datetime.time.fromisoformat( form.heure_debut.data or debut_jour ) except ValueError: form.set_error("heure début invalide", form.heure_debut) if bool(form.heure_debut.data) != bool(form.heure_fin.data): form.set_error( "Les deux heures début et fin doivent être spécifiées, ou aucune" ) try: heure_fin = datetime.time.fromisoformat(form.heure_fin.data or fin_jour) except ValueError: form.set_error("heure fin invalide", form.heure_fin) if not form.ok: return False, None, None, None # Vérifie cohérence des dates/heures dt_debut = datetime.datetime.combine(date_debut, heure_debut) dt_fin = datetime.datetime.combine(date_fin or date_debut, heure_fin) if dt_fin <= dt_debut: form.set_error("dates début/fin incohérentes") # La date de dépôt (si vide, la date actuelle) try: dt_entry_date = ( datetime.datetime.strptime(form.entry_date.data, scu.DATE_FMT) if form.entry_date.data else datetime.datetime.now() # local tz ) except ValueError: dt_entry_date = None form.set_error("format de date de dépôt invalide", form.entry_date) # L'heure de dépôt try: entry_time = datetime.time.fromisoformat( form.entry_time.data or datetime.datetime.now().time().isoformat("seconds") ) except ValueError: dt_entry_date = None form.set_error("format d'heure de dépôt invalide", form.entry_date) if dt_entry_date: dt_entry_date = datetime.datetime.combine(dt_entry_date, entry_time) # Ajoute time zone serveur dt_debut_tz_server = scu.TIME_ZONE.localize(dt_debut) dt_fin_tz_server = scu.TIME_ZONE.localize(dt_fin) if from_justif: cas: list[bool] = [ # cas 1 (date de fin vide et pas d'heure de début) not form.date_fin.data and not form.heure_debut.data, # cas 2 (date de fin et pas d'heures) form.date_fin.data != "" and not form.heure_debut.data, ] if any(cas): dt_debut_tz_server = dt_debut_tz_server.replace(hour=0, minute=0) dt_fin_tz_server = dt_fin_tz_server.replace(hour=23, minute=59) # Vérification dates contenu dans un semestre de l'étudiant dates_semestres: list[tuple[datetime.date, datetime.date]] = ( [(sem.date_debut, sem.date_fin) for sem in etud.get_formsemestres()] if formsemestre is None else [(formsemestre.date_debut, formsemestre.date_fin)] ) # Vérification date début if not any( [ dt_debut_tz_server.date() >= deb and dt_debut_tz_server.date() <= fin for deb, fin in dates_semestres ] ): form.set_error( ( "La date de début n'appartient à aucun semestre de l'étudiant" if formsemestre is None else "La date de début n'appartient pas au semestre" ), form.date_debut, ) # Vérification date fin if form.date_fin.data and not any( [ dt_fin_tz_server.date() >= deb and dt_fin_tz_server.date() <= fin for deb, fin in dates_semestres ] ): form.set_error( ( "La date de fin n'appartient à aucun semestre de l'étudiant" if not formsemestre else "La date de fin n'appartient pas au semestre" ), form.date_fin, ) dt_entry_date_tz_server = ( scu.TIME_ZONE.localize(dt_entry_date) if dt_entry_date else None ) return form.ok, dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server def _record_assiduite_etud( etud: Identite, form: AjoutAssiduiteEtudForm, formsemestre: FormSemestre | None = None, ) -> bool: """Enregistre les données du formulaire de saisie assiduité. Returns ok if successfully recorded, else put error info in the form. Format attendu des données du formulaire: form.assi_etat.data : 'absent' form.date_debut.data : '05/12/2023' form.heure_debut.data : '09:06' (heure locale du serveur) """ ( ok, dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server, ) = _get_dates_from_assi_form(form, etud, formsemestre=formsemestre) # Le module (avec "autre") mod_data = form.modimpl.data if mod_data: if mod_data == "autre": moduleimpl_id = "autre" else: try: moduleimpl_id = int(mod_data) except ValueError: form.modimpl.error("choix de module invalide") ok = False else: moduleimpl_id = None if not ok: return False external_data = None moduleimpl: ModuleImpl | None = None match moduleimpl_id: case "autre": external_data = {"module": "Autre"} case None: moduleimpl = None case _: moduleimpl = ModuleImpl.query.get(moduleimpl_id) try: assi_etat: scu.EtatAssiduite = scu.EtatAssiduite.get(form.assi_etat.data) ass = Assiduite.create_assiduite( etud, dt_debut_tz_server, dt_fin_tz_server, assi_etat, description=form.description.data, entry_date=dt_entry_date_tz_server, external_data=external_data, moduleimpl=moduleimpl, notify_mail=True, user_id=current_user.id, ) db.session.add(ass) db.session.commit() if assi_etat != scu.EtatAssiduite.PRESENT and form.est_just.data: # si la case "justifiée est cochée alors on créé un justificatif de même période" # L'état est Valide si l'user à la permission JustifValidate etat: scu.EtatJustificatif = scu.EtatJustificatif.ATTENTE if current_user.has_permission(Permission.JustifValidate): etat = scu.EtatJustificatif.VALIDE else: flash( "Vous ne pouvez pas créer de justificatif valide," + " il est automatiquement passé 'EN ATTENTE'", ) justi: Justificatif = Justificatif.create_justificatif( etudiant=etud, date_debut=dt_debut_tz_server, date_fin=dt_fin_tz_server, etat=etat, user_id=current_user.id, ) # On met à jour les assiduités en fonction du nouveau justificatif justi.justifier_assiduites() # Invalider cache scass.simple_invalidate_cache(ass.to_dict(), etud.id) return True except ScoValueError as exc: err: str = f"Erreur: {exc.args[0]}" if ( exc.args[0] == "Duplication: la période rentre en conflit avec une plage enregistrée" ): # Récupération de la première assiduité conflictuelle conflits: Query = etud.assiduites.filter( Assiduite.date_debut < dt_fin_tz_server, Assiduite.date_fin > dt_debut_tz_server, ) assi: Assiduite = conflits.first() lien: str = url_for( "assiduites.edit_assiduite_etud", assiduite_id=assi.assiduite_id, scodoc_dept=g.scodoc_dept, ) form.set_error( Markup( err + f' (conflit)" ) ) else: form.set_error(err) return False @bp.route("/bilan_etud") @scodoc @permission_required(Permission.ScoView) def bilan_etud(): """ bilan_etud Affichage de toutes les assiduites et justificatifs d'un etudiant Args: etudid (int): l'identifiant de l'étudiant Returns: str: l'html généré """ # Initialisation des options du tableau options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions( show_module=True ) # Récupération de l'étudiant etudid = request.args.get("etudid", -1) etud: Identite = Identite.query.get_or_404(etudid) if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") # Gestion du filtre de module moduleimpl_id = request.args.get("moduleimpl_id", None) if moduleimpl_id is not None: try: moduleimpl_id = int(moduleimpl_id) except ValueError: moduleimpl_id = None options.moduleimpl_id = moduleimpl_id # Gestion des dates du bilan (par défaut l'année scolaire) date_debut = scu.date_debut_annee_scolaire().strftime(scu.DATE_FMT) date_fin: str = scu.date_fin_annee_scolaire().strftime(scu.DATE_FMT) # Récupération de la métrique d'assiduité assi_metric = scu.translate_assiduites_metric( sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id), ) # préparation du selecteur de moduleimpl annee_sco = _get_anne_sco_from_request() moduleimpl_select: str = _module_selector_multiple( etud, moduleimpl_id, no_default=True, annee_sco=annee_sco ) # Préparation de la page tableau = _prepare_tableau( liste_assi.AssiJustifData.from_etudiants(etud), filename=f"assiduites-justificatifs-{etud.id}", afficher_etu=False, filtre=liste_assi.AssiFiltre(type_obj=0), options=options, cache_key=f"tableau-etud-{etud.id}", moduleimpl_select=moduleimpl_select, ) if not tableau[0]: return tableau[1] # Génération de la page HTML return render_template( "assiduites/pages/bilan_etud.j2", assi_metric=assi_metric, assi_seuil=_get_seuil(), date_debut=date_debut, date_fin=date_fin, sco=ScoData(etud), tableau=tableau[1], ) @bp.route("/edit_justificatif_etud/", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) def edit_justificatif_etud(justif_id: int): """ Edition d'un justificatif. Il faut de plus la permission pour voir/modifier la raison. Args: justif_id (int): l'identifiant du justificatif Returns: str: l'html généré """ try: justif = Justificatif.get_justificatif(justif_id) except HTTPException: flash("Justificatif invalide") return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept)) readonly = not current_user.has_permission(Permission.AbsChange) form = AjoutJustificatifEtudForm(obj=justif) # Limite les choix d'état si l'utilisateur n'a pas la permission de valider choix_etat: list = [ (scu.EtatJustificatif.ATTENTE.value, "En attente de validation") ] if current_user.has_permission(Permission.JustifValidate): choix_etat = [ ("", "Choisir..."), (scu.EtatJustificatif.ATTENTE.value, "En attente de validation"), (scu.EtatJustificatif.NON_VALIDE.value, "Non valide"), (scu.EtatJustificatif.MODIFIE.value, "Modifié"), (scu.EtatJustificatif.VALIDE.value, "Valide"), ] form.etat.choices = choix_etat if readonly: form.disable_all() # Set the default value for the etat field if request.method == "GET": form.date_debut.data = justif.date_debut.strftime(scu.DATE_FMT) form.date_fin.data = justif.date_fin.strftime(scu.DATE_FMT) if form.date_fin.data == form.date_debut.data: # un seul jour: pas de date de fin, indique les heures form.date_fin.data = "" form.heure_debut.data = justif.date_debut.strftime(scu.TIME_FMT) form.heure_fin.data = justif.date_fin.strftime(scu.TIME_FMT) form.entry_date.data = ( justif.entry_date.strftime(scu.DATE_FMT) if justif.entry_date else "" ) form.entry_time.data = ( justif.entry_date.strftime(scu.TIME_FMT) if justif.entry_date else "" ) form.etat.data = str(justif.etat) back_url = request.args.get("back_url", None) redirect_url = back_url or url_for( "assiduites.bilan_etud", scodoc_dept=g.scodoc_dept, etudid=justif.etudiant.id, ) if form.validate_on_submit(): if form.cancel.data or not current_user.has_permission( Permission.AbsChange ): # cancel button return redirect(redirect_url) if _record_justificatif_etud(justif.etudiant, form, justif): return redirect(redirect_url) # Fichiers filenames, nb_files = justif.get_fichiers() return render_template( "assiduites/pages/ajout_justificatif_etud.j2", can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView) or current_user.id == justif.user_id, etud=justif.etudiant, filenames=filenames, form=form, justif=_preparer_objet("justificatif", justif), nb_files=nb_files, title=f"Modification justificatif absence de {justif.etudiant.html_link_fiche()}", redirect_url=redirect_url, sco=ScoData(justif.etudiant), scu=scu, readonly=not current_user.has_permission(Permission.AbsChange), ) @bp.route("/ajout_justificatif_etud", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def ajout_justificatif_etud(): """ ajout_justificatif_etud : Affichage et création des justificatifs de l'étudiant Args: etudid (int): l'identifiant de l'étudiant Returns: str: l'html généré """ etud = Identite.get_etud(request.args.get("etudid")) redirect_url = url_for( "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ) form = AjoutJustificatifEtudForm() # Limite les choix d'état si l'utilisateur n'a pas la permission de valider choix_etat: list = [ (scu.EtatJustificatif.ATTENTE.value, "En attente de validation") ] if current_user.has_permission(Permission.JustifValidate): choix_etat = [ ("", "Choisir..."), (scu.EtatJustificatif.ATTENTE.value, "En attente de validation"), (scu.EtatJustificatif.NON_VALIDE.value, "Non valide"), (scu.EtatJustificatif.MODIFIE.value, "Modifié"), (scu.EtatJustificatif.VALIDE.value, "Valide"), ] form.etat.choices = choix_etat if form.validate_on_submit(): if form.cancel.data: # cancel button return redirect(redirect_url) ok = _record_justificatif_etud(etud, form) if ok: return redirect(redirect_url) is_html, tableau = _prepare_tableau( liste_assi.AssiJustifData.from_etudiants( etud, ), filename=f"justificatifs-{etud.nom or ''}", afficher_etu=False, filtre=liste_assi.AssiFiltre(type_obj=2), options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True), afficher_options=False, titre="Justificatifs enregistrés pour cet étudiant", cache_key=f"tableau-etud-{etud.id}", ) if not is_html: return tableau return render_template( "assiduites/pages/ajout_justificatif_etud.j2", etud=etud, form=form, title=f"Ajout justificatif absence pour {etud.html_link_fiche()}", redirect_url=redirect_url, sco=ScoData(etud), scu=scu, tableau=tableau, ) def _record_justificatif_etud( etud: Identite, form: AjoutJustificatifEtudForm, justif: Justificatif | None = None ) -> bool: """Enregistre les données du formulaire de saisie justificatif (et ses fichiers). Returns ok if successfully recorded, else put error info in the form. Format attendu des données du formulaire: form.assi_etat.data : 'absent' form.date_debut.data : '05/12/2023' form.heure_debut.data : '09:06' (heure locale du serveur) Si justif, modifie le justif existant, sinon en crée un nouveau """ ( ok, dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server, ) = _get_dates_from_assi_form(form, etud, from_justif=True) if not ok: log("_record_justificatif_etud: dates invalides") form.set_error("Erreur: dates invalides") return False if not form.etat.data: log("_record_justificatif_etud: etat invalide") form.set_error("Erreur: état invalide") return False etat = int(form.etat.data) if not scu.EtatJustificatif.is_valid_etat(etat): log(f"_record_justificatif_etud: etat invalide ({etat})") form.set_error("Erreur: état invalide") return False if ( not current_user.has_permission(Permission.JustifValidate) and etat != scu.EtatJustificatif.ATTENTE ): log("_record_justificatif_etud: pas la permission") form.set_error( "Erreur: vous n'avez pas la permission de définir la validité d'un justificatif" ) return False try: message = "" if justif: form.date_debut.data = dt_debut_tz_server form.date_fin.data = dt_fin_tz_server form.entry_date.data = dt_entry_date_tz_server justif.dejustifier_assiduites() if justif.edit_from_form(form): message = "Justificatif modifié" # On met à jour la db pour avoir les bonnes donnés pour le journal etud db.session.add(justif) db.session.commit() Scolog.logdb( method="edit_justificatif", etudid=etud.id, msg=f"justificatif modif: {justif}", ) else: message = "Pas de modification" fichier_suppr: list[str] = request.form.getlist("suppr_fichier_just") if len(fichier_suppr) > 0 and justif.fichier is not None: archiver: JustificatifArchiver = JustificatifArchiver() for fichier in fichier_suppr: archiver.delete_justificatif(etud, justif.fichier, fichier) flash(f"Fichier {fichier} supprimé") else: justif = Justificatif.create_justificatif( etud, dt_debut_tz_server, dt_fin_tz_server, etat=etat, raison=form.raison.data, entry_date=dt_entry_date_tz_server, user_id=current_user.id, ) message = "Justificatif créé" db.session.add(justif) if not _upload_justificatif_files(justif, form): flash("Erreur enregistrement fichiers") log("problem in _upload_justificatif_files, rolling back") db.session.rollback() return False db.session.commit() justif.justifier_assiduites() scass.simple_invalidate_cache(justif.to_dict(), etud.id) flash(message) return True except ScoValueError as exc: log(f"_record_justificatif_etud: erreur {exc.args[0]}") db.session.rollback() form.set_error(f"Erreur: {exc.args[0]}") return False def _upload_justificatif_files( just: Justificatif, form: AjoutJustificatifEtudForm ) -> bool: """Enregistre les fichiers du formulaire de création de justificatif""" # Utilisation de l'archiver de justificatifs archiver: JustificatifArchiver = JustificatifArchiver() archive_name: str = just.fichier try: # On essaye de sauvegarder les fichiers for file in form.fichiers.data or []: archive_name, _ = archiver.save_justificatif( just.etudiant, filename=file.filename, data=file.stream.read(), archive_name=archive_name, user_id=current_user.id, ) flash(f"Fichier {file.filename} enregistré") if form.fichiers.data: # On actualise l'archive du justificatif just.fichier = archive_name db.session.add(just) db.session.commit() return True except ScoValueError as exc: log( f"_upload_justificatif_files: error on {file.filename} for etud {just.etudid}" ) form.set_error(f"Erreur sur fichier justificatif: {exc.args[0]}") return False @bp.route("/calendrier_assi_etud") @scodoc @permission_required(Permission.ScoView) def calendrier_assi_etud(): """ Affichage d'un calendrier de l'assiduité de l'étudiant Args: etudid (int): l'identifiant de l'étudiant Returns: str: l'html généré """ # Récupération de l'étudiant etudid = request.args.get("etudid", -1) etud: Identite = Identite.query.get_or_404(etudid) if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") # Options mode_demi: bool = scu.to_bool(request.args.get("mode_demi", "t")) show_pres: bool = scu.to_bool(request.args.get("show_pres", "f")) show_reta: bool = scu.to_bool(request.args.get("show_reta", "f")) annee: int = int(request.args.get("annee", scu.annee_scolaire())) # Récupération des années d'étude de l'étudiant annees: list[int] = [] for ins in etud.formsemestre_inscriptions: date_deb = ins.formsemestre.date_debut date_fin = ins.formsemestre.date_fin annees.extend( [ scu.annee_scolaire_repr(date_deb.year, date_deb.month), scu.annee_scolaire_repr(date_fin.year, date_fin.month), ] ) annees = sorted(annees, reverse=True) # Transformation en une liste "json" # (sera utilisé pour générer le selecteur d'année) annees_str: str = json.dumps(annees) cal = CalendrierAssi( annee, etud, mode_demi=mode_demi, show_pres=show_pres, show_reta=show_reta, ) calendrier: str = cal.get_html() # Peuplement du template jinja return render_template( "assiduites/pages/calendrier_assi_etud.j2", sco=ScoData(etud), annee=annee, nonworkdays=_non_work_days(), annees=annees_str, calendrier=calendrier, mode_demi=mode_demi, show_pres=show_pres, show_reta=show_reta, ) @bp.route("/signal_assiduites_group") @scodoc @permission_required(Permission.AbsChange) def signal_assiduites_group(): """ signal_assiduites_group Saisie des assiduités des groupes pour le jour donné Returns: str: l'html généré """ # Récupération des paramètres de l'url # formsemestre_id est optionnel si modimpl est indiqué formsemestre_id: int = request.args.get("formsemestre_id", -1) moduleimpl_id: int = request.args.get("moduleimpl_id") date: str = request.args.get("day", datetime.date.today().isoformat()) heures: list[str] = [ request.args.get("heure_deb", ""), request.args.get("heure_fin", ""), ] group_ids: list[int] = request.args.get("group_ids", None) if group_ids is None: group_ids = [] else: group_ids = group_ids.split(",") map(str, group_ids) # Vérification du moduleimpl_id try: moduleimpl_id = int(moduleimpl_id) except (TypeError, ValueError): moduleimpl_id = None if moduleimpl_id is not None and moduleimpl_id >= 0: modimpl = ModuleImpl.get_modimpl(moduleimpl_id) else: modimpl = None # Vérification du formsemestre_id try: formsemestre_id = int(formsemestre_id) except (TypeError, ValueError): formsemestre_id = None if (formsemestre_id < 0 or formsemestre_id is None) and modimpl: # si le module est spécifié mais pas le semestre: formsemestre_id = modimpl.formsemestre_id # Gestion des groupes groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id, select_all_when_unspecified=True, ) if not groups_infos.members: return ( html_sco_header.sco_header(page_title="Saisie de l'assiduité") + "

Aucun étudiant !

" + html_sco_header.sco_footer() ) # --- Filtrage par formsemestre --- formsemestre_id = groups_infos.formsemestre_id formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) if formsemestre.dept_id != g.scodoc_dept_id: abort(404, "groupes inexistants dans ce département") # Récupération des étudiants des groupes etuds = [ sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] for m in groups_infos.members ] # --- Vérification de la date --- real_date = scu.is_iso_formated(date, True).date() if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin: # Si le jour est hors semestre, renvoyer vers choix date flash( "La date sélectionnée n'est pas dans le semestre. Choisissez une autre date." ) return sco_gen_cal.calendrier_choix_date( formsemestre.date_debut, formsemestre.date_fin, url=url_for( "assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, group_ids=",".join(group_ids), moduleimpl_id=moduleimpl_id, day="placeholder", ), mode="jour", titre="Choix de la date", ) # --- Restriction en fonction du moduleimpl_id --- if moduleimpl_id: mod_inscrits = { x["etudid"] for x in sco_moduleimpl.do_moduleimpl_inscription_list( moduleimpl_id=moduleimpl_id ) } etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits] if etuds_inscrits_module: etuds = etuds_inscrits_module else: # Si aucun etudiant n'est inscrit au module choisi... moduleimpl_id = None # Récupération du nom des/du groupe(s) if groups_infos.tous_les_etuds_du_sem: gr_tit = "en" else: if len(groups_infos.group_ids) > 1: grp = "des groupes" else: grp = "du groupe" gr_tit = ( grp + ' ' + groups_infos.groups_titles + "" ) # Récupération du semestre en dictionnaire sem = formsemestre.to_dict() # Page HTML return render_template( "assiduites/pages/signal_assiduites_group.j2", date=_dateiso_to_datefr(date), defdem=_get_etuds_dem_def(formsemestre), forcer_module=sco_preferences.get_preference( "forcer_module", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ), non_present=sco_preferences.get_preference( "non_present", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ), formsemestre_date_debut=str(formsemestre.date_debut), formsemestre_date_fin=str(formsemestre.date_fin), formsemestre_id=formsemestre_id, gr_tit=gr_tit, grp=sco_groups_view.menu_groups_choice(groups_infos), minitimeline=_mini_timeline(), moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), nonworkdays=_non_work_days(), readonly="false", sco=ScoData(formsemestre=formsemestre), sem=sem["titre_num"], timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), title="Saisie de l'assiduité", ) class RowEtudWithAssi(RowEtud): """Ligne de la table d'étudiants avec colonne Assiduité""" def __init__( self, table: TableEtud, etud: Identite, etat_assiduite: str, est_just: bool, *args, **kwargs, ): super().__init__(table, etud, *args, **kwargs) self.etat_assiduite = etat_assiduite self.est_just = est_just # remplace lien vers fiche par lien vers calendrier self.target_url = url_for( "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ) self.target_title = f"Calendrier de {etud.nomprenom}" def add_etud_cols(self): """Ajoute colonnes pour cet étudiant""" super().add_etud_cols() self.add_cell( "assi-type", "Présence", self.etat_assiduite, "assi-type", ) self.classes += ["row-assiduite", self.etat_assiduite.lower()] if self.est_just: self.classes += ["justifiee"] @bp.route("/etat_abs_date") @scodoc @permission_required(Permission.ScoView) def etat_abs_date(): """Tableau de l'état d'assiduité d'un ou plusieurs groupes sur la plage de dates date_debut, date_fin. group_ids : ids de(s) groupe(s) date_debut, date_fin: format ISO evaluation_id: optionnel, évaluation concernée, pour titre et liens. date_debut, date_fin en ISO fmt : format export (xls, défaut html) """ # Récupération des paramètres de la requête date_debut_str = request.args.get("date_debut") date_fin_str = request.args.get("date_fin") fmt = request.args.get("fmt", "html") group_ids = request.args.getlist("group_ids", int) evaluation_id = request.args.get("evaluation_id") evaluation: Evaluation = ( Evaluation.query.get_or_404(evaluation_id) if evaluation_id is not None else None ) # Vérification des dates try: date_debut = datetime.datetime.fromisoformat(date_debut_str) except ValueError as exc: raise ScoValueError("date_debut invalide") from exc try: date_fin = datetime.datetime.fromisoformat(date_fin_str) except ValueError as exc: raise ScoValueError("date_fin invalide") from exc # Les groupes: groups = [GroupDescr.query.get_or_404(group_id) for group_id in group_ids] # Les étudiants de tous les groupes sélectionnés, flat list etuds = [ etud for gr_etuds in [group.etuds for group in groups] for etud in gr_etuds ] # Récupération des assiduites des étudiants assiduites: Assiduite = Assiduite.query.filter( Assiduite.etudid.in_([etud.id for etud in etuds]) ) # Filtrage des assiduités en fonction des dates données assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin) # Génération table table = TableEtud(row_class=RowEtudWithAssi) for etud in sorted(etuds, key=lambda e: e.sort_key): # On récupère l'état de la première assiduité sur la période assi = assiduites.filter_by(etudid=etud.id).first() etat = "" if assi is not None: if assi.etat != scu.EtatAssiduite.PRESENT: etat = scu.EtatAssiduite.inverse().get(assi.etat).name row = table.row_class(table, etud, etat, assi.est_just) row.add_etud_cols() table.add_row(row) if fmt.startswith("xls"): return scu.send_file( table.excel(), filename=f"assiduite-eval-{date_debut.isoformat()}", mime=scu.XLSX_MIMETYPE, suffix=scu.XLSX_SUFFIX, ) return render_template( "assiduites/pages/etat_abs_date.j2", date_debut=date_debut, date_fin=date_fin, evaluation=evaluation, etuds=etuds, group_title=", ".join(gr.get_nom_with_part("tous") for gr in groups), sco=ScoData(), table=table, ) @bp.route("/visu_assi_group") @scodoc @permission_required(Permission.ScoView) def visu_assi_group(): """Visualisation de l'assiduité d'un groupe entre deux dates. Paramètres: - date_debut, date_fin (format ISO) - fmt : format d'export, html (défaut) ou xls - group_ids : liste des groupes - formsemestre_modimpls_id: id d'un formasemestre, si fournit restreint les comptages aux assiduités liées à des modules de ce formsemestre. """ # Récupération des paramètres de la requête dates = { "debut": request.args.get("date_debut"), "fin": request.args.get("date_fin"), } formsemestre_modimpls_id = request.args.get("formsemestre_modimpls_id") formsemestre_modimpls = ( None if formsemestre_modimpls_id is None else FormSemestre.get_formsemestre(formsemestre_modimpls_id) ) fmt = request.args.get("fmt", "html") group_ids: list[int] = request.args.get("group_ids", None) if group_ids is None: group_ids = [] else: group_ids = group_ids.split(",") map(str, group_ids) # Récupération des groupes, du semestre et des étudiants groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) formsemestre = db.session.get(FormSemestre, groups_infos.formsemestre_id) etuds = etuds_sorted_from_ids([m["etudid"] for m in groups_infos.members]) # Génération du tableau des assiduités table: TableAssi = TableAssi( etuds=etuds, dates=list(dates.values()), formsemestre=formsemestre, formsemestre_modimpls=formsemestre_modimpls, convert_values=(fmt == "html"), ) # Export en XLS if fmt.startswith("xls"): return scu.send_file( table.excel(), filename=f"assiduite-{groups_infos.groups_filename}", mime=scu.XLSX_MIMETYPE, suffix=scu.XLSX_SUFFIX, ) # récupération du/des noms du/des groupes if groups_infos.tous_les_etuds_du_sem: gr_tit = "" grp = "" else: if len(groups_infos.group_ids) > 1: grp = "des groupes" else: grp = "du groupe" gr_tit = ( grp + ' ' + groups_infos.groups_titles + "" ) # Génération de la page return render_template( "assiduites/pages/visu_assi_group.j2", assi_metric=scu.translate_assiduites_metric( scu.translate_assiduites_metric( sco_preferences.get_preference( "assi_metrique", dept_id=g.scodoc_dept_id ), ), inverse=False, short=False, ), date_debut=_dateiso_to_datefr(dates["debut"]), date_fin=_dateiso_to_datefr(dates["fin"]), gr_tit=gr_tit, group_ids=request.args.get("group_ids", None), sco=ScoData(formsemestre=groups_infos.get_formsemestre()), tableau=table.html(), title=f"Assiduité {grp} {groups_infos.groups_titles}", ) def _get_anne_sco_from_request() -> int | None: """La valeur du paramètreannee_sco de la requête GET, ou None si absent""" annee_sco: str | None = request.args.get("annee_sco", None) # Vérification de l'année scolaire if annee_sco is not None: try: return int(annee_sco) except (ValueError, TypeError): pass return None def _prepare_tableau( data: liste_assi.AssiJustifData, filename: str = "tableau-assiduites", afficher_etu: bool = True, filtre: liste_assi.AssiFiltre = None, options: liste_assi.AssiDisplayOptions = None, afficher_options: bool = True, moduleimpl_select: str = None, titre="Évènements enregistrés pour cet étudiant", cache_key: str = "", force_options: dict[str, object] = None, annee_sco: int | None = None, ) -> tuple[bool, Response | str]: """ Prépare un tableau d'assiduités / justificatifs Cette fonction récupère dans la requête les arguments : annee_sco : int -> XXX n_page : int -> XXX page_number : int -> XXX show_pres : bool -> Affiche les présences, par défaut False show_reta : bool -> Affiche les retard, par défaut False show_desc : bool -> Affiche les descriptions, par défaut False Returns: tuple[bool | Reponse|str ]: - bool : Vrai si la réponse est du Text/HTML - Reponse : du Text/HTML ou Une Reponse (téléchargement fichier) """ show_pres: bool | str = request.args.get("show_pres", False) show_reta: bool | str = request.args.get("show_reta", False) show_desc: bool | str = request.args.get("show_desc", False) annee_sco = _get_anne_sco_from_request() if annee_sco is None else annee_sco nb_ligne_page: int = request.args.get("nb_ligne_page") # Vérification de nb_ligne_page try: nb_ligne_page: int = int(nb_ligne_page) except (ValueError, TypeError): nb_ligne_page = liste_assi.ListeAssiJusti.NB_PAR_PAGE page_number: int = request.args.get("n_page", 1) # Vérification de page_number try: page_number: int = int(page_number) except (ValueError, TypeError): page_number = 1 fmt = request.args.get("fmt", "html") # Ordre ordre: tuple[str, str | bool] = None ordre_col: str = request.args.get("order_col", None) ordre_tri: str = request.args.get("order", None) if ordre_col is not None and ordre_tri is not None: ordre = (ordre_col, ordre_tri == "ascending") if options is None: options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions() options.remplacer( page=page_number, nb_ligne_page=nb_ligne_page, show_pres=show_pres, show_reta=show_reta, show_desc=show_desc, show_etu=afficher_etu, order=ordre, annee_sco=annee_sco, ) if force_options is not None: options.remplacer(**force_options) table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( table_data=data, options=options, filtre=filtre, no_pagination=fmt.startswith("xls"), titre=cache_key, ) if fmt.startswith("xls"): return False, scu.send_file( table.excel(), filename=filename, mime=scu.XLSX_MIMETYPE, suffix=scu.XLSX_SUFFIX, ) return True, render_template( "assiduites/widgets/tableau.j2", table=table, total_pages=table.total_pages, options=options, afficher_options=afficher_options, titre=titre, moduleimpl_select=moduleimpl_select, ) @bp.route("/recup_assiduites_plage", methods=["POST"]) @scodoc @permission_required(Permission.AbsChange) def recup_assiduites_plage(): """ Renvoie un fichier excel contenant toutes les assiduités d'une plage La plage est définie par les valeurs "datedeb" et "datefin" du formulaire Par défaut tous les étudiants du département sont concernés Si le champs "formsemestre_id" est présent dans le formulaire et est non vide, seuls les étudiants inscrits dans ce semestre sont concernés. """ date_deb: datetime.datetime = request.form.get("datedeb") date_fin: datetime.datetime = request.form.get("datefin") # Vérification des dates try: date_deb = datetime.datetime.strptime(date_deb, scu.DATE_FMT) except ValueError as exc: raise ScoValueError("date_debut invalide", dest_url=request.referrer) from exc try: date_fin = datetime.datetime.strptime(date_fin, scu.DATE_FMT) except ValueError as exc: raise ScoValueError("date_fin invalide", dest_url=request.referrer) from exc # Récupération des étudiants etuds: Query = [] formsemestre_id: str | None = request.form.get("formsemestre_id") name: str = "" if formsemestre_id is not None and formsemestre_id != "": formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) etuds = formsemestre.etuds name = formsemestre.session_id() else: dept: Departement = Departement.query.get_or_404(g.scodoc_dept_id) etuds = dept.etudiants name = dept.acronym # Récupération des assiduités/justificatifs etudids: list[int] = [etud.id for etud in etuds] assiduites: Query = Assiduite.query.filter(Assiduite.etudid.in_(etudids)) justificatifs: Query = Justificatif.query.filter(Justificatif.etudid.in_(etudids)) # Filtrage des assiduités/justificatifs en fonction des dates données assiduites = scass.filter_by_date(assiduites, Assiduite, date_deb, date_fin) justificatifs = scass.filter_by_date( justificatifs, Justificatif, date_deb, date_fin ) table_data: liste_assi.AssiJustifData = liste_assi.AssiJustifData( assiduites_query=assiduites, justificatifs_query=justificatifs, ) options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions( show_pres=True, show_reta=True, show_module=True, show_desc=True, show_etu=True, annee_sco=-1, ) date_deb_str: str = date_deb.strftime("%d-%m-%Y") date_fin_str: str = date_fin.strftime("%d-%m-%Y") filename: str = f"assiduites_{name}_{date_deb_str}_{date_fin_str}" tableau: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( table_data, options=options, titre="tableau-dept-" + filename, no_pagination=True, ) return scu.send_file( tableau.excel(), filename=filename, mime=scu.XLSX_MIMETYPE, suffix=scu.XLSX_SUFFIX, ) @bp.route("/tableau_assiduite_actions", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def tableau_assiduite_actions(): """Edition/suppression/information sur une assiduité ou un justificatif type = "assiduite" | "justificatif" action = "supprimer" | "details" | "justifier" """ obj_type: str = request.args.get("type", "assiduite") action: str = request.args.get("action", "details") obj_id: str = int(request.args.get("obj_id", -1)) objet: Assiduite | Justificatif objet_name = "" if obj_type == "assiduite": objet: Assiduite = Assiduite.query.get_or_404(obj_id) objet_name = scu.EtatAssiduite(objet.etat).version_lisible() else: objet: Justificatif = Justificatif.query.get_or_404(obj_id) objet_name = "Justificatif" # Suppression : attention, POST ou GET ! if action == "supprimer": objet.supprime() flash(f"{objet_name} supprimé") return redirect(request.referrer) # Justification d'une assiduité depuis le tableau if action == "justifier" and obj_type == "assiduite": # L'état est Valide si l'user à la permission JustifValidate etat: scu.EtatJustificatif = scu.EtatJustificatif.ATTENTE if current_user.has_permission(Permission.JustifValidate): etat = scu.EtatJustificatif.VALIDE else: flash( "Vous ne pouvez pas créer de justificatif valide," + " il est automatiquement passé 'EN ATTENTE'", ) # Création du justificatif correspondant justificatif_correspondant: Justificatif = Justificatif.create_justificatif( etudiant=objet.etudiant, date_debut=objet.date_debut, date_fin=objet.date_fin, etat=etat, user_id=current_user.id, ) justificatif_correspondant.justifier_assiduites() scass.simple_invalidate_cache( justificatif_correspondant.to_dict(), objet.etudiant.id ) if etat == scu.EtatJustificatif.VALIDE: flash(f"{objet_name} justifiée") return redirect(request.referrer) # Si on arrive ici, c'est que l'action n'est pas autorisée # cette fonction ne sert plus qu'à supprimer ou justifier flash("Méthode non autorisée", "error") return redirect(request.referrer) def _preparer_objet( obj_type: str, objet: Assiduite | Justificatif, sans_gros_objet: bool = False ) -> dict: "Préparation d'un objet pour simplifier l'affichage jinja" objet_prepare: dict = objet.to_dict() if obj_type == "assiduite": objet_prepare["etat"] = ( scu.EtatAssiduite(objet.etat).version_lisible().capitalize() ) objet_prepare["real_etat"] = scu.EtatAssiduite(objet.etat).name.lower() objet_prepare["description"] = ( "" if objet.description is None else objet.description ) objet_prepare["description"] = objet_prepare["description"].strip() # Gestion du module objet_prepare["module"] = objet.get_module(True) # Gestion justification objet_prepare["justification"] = { "est_just": objet.est_just, "justificatifs": [], } if not sans_gros_objet: justificatifs: list[int] = get_assiduites_justif(objet.assiduite_id, False) for justi_id in justificatifs: justi: Justificatif = Justificatif.query.get(justi_id) objet_prepare["justification"]["justificatifs"].append( _preparer_objet("justificatif", justi, sans_gros_objet=True) ) else: # objet == "justificatif" justif: Justificatif = objet objet_prepare["etat"] = ( scu.EtatJustificatif(justif.etat).version_lisible().capitalize() ) objet_prepare["real_etat"] = scu.EtatJustificatif(justif.etat).name.lower() objet_prepare["raison"] = "" if justif.raison is None else justif.raison objet_prepare["raison"] = objet_prepare["raison"].strip() objet_prepare["justification"] = {"assiduites": [], "fichiers": {}} if not sans_gros_objet: assiduites: list[Assiduite] = justif.get_assiduites() for assi in assiduites: objet_prepare["justification"]["assiduites"].append( _preparer_objet("assiduite", assi, sans_gros_objet=True) ) # fichiers justificatifs archivés: filenames, nb_files = justif.get_fichiers() objet_prepare["justification"]["fichiers"] = { "total": nb_files, "filenames": filenames, } objet_prepare["date_fin"] = objet.date_fin.strftime(scu.DATEATIME_FMT) objet_prepare["real_date_fin"] = objet.date_fin.isoformat() objet_prepare["date_debut"] = objet.date_debut.strftime(scu.DATEATIME_FMT) objet_prepare["real_date_debut"] = objet.date_debut.isoformat() objet_prepare["entry_date"] = objet.entry_date.strftime(scu.DATEATIME_FMT) objet_prepare["etud_nom"] = objet.etudiant.nomprenom if objet.user_id is not None: user: User = User.query.get(objet.user_id) objet_prepare["saisie_par"] = user.get_nomprenom() else: objet_prepare["saisie_par"] = "Inconnu" return objet_prepare @bp.route("/signale_evaluation_abs//") @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) def traitement_justificatifs(): """Page de traitement des justificatifs On traite les justificatifs par formsemestre On peut Valider, Invalider ou mettre en ATT """ # Récupération du formsemestre formsemestre_id: int = request.args.get("formsemestre_id", -1) formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) lignes: list[dict] = [] # Récupération des justificatifs justificatifs_query: Query = scass.filter_by_formsemestre( Justificatif.query, Justificatif, formsemestre ) justificatifs_query = justificatifs_query.filter( Justificatif.etat.in_( [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE] ) ).order_by(Justificatif.date_debut) justif: Justificatif for justif in justificatifs_query: etud: Identite = justif.etudiant assi_stats: tuple[int, int, int] = scass.get_assiduites_count( etud.id, formsemestre.to_dict() ) etud_dict: dict = { "id": etud.id, "nom": etud.nom, "prenom": etud.prenom, "nomprenom": etud.nomprenom, "stats": assi_stats, "sort_key": etud.sort_key, } assiduites_justifiees: list[Assiduite] = justif.get_assiduites().all() # fichiers justificatifs archivés: filenames, nb_files = justif.get_fichiers() fichiers = { "total": nb_files, "filenames": filenames, } lignes.append( { "etud": etud_dict, "justif": justif, "assiduites": assiduites_justifiees, "fichiers": fichiers, "etat": scu.EtatJustificatif(justif.etat).name.lower(), } ) # Tri en fonction du nom des étudiants lignes = sorted(lignes, key=lambda x: x["etud"]["sort_key"]) return render_template( "assiduites/pages/traitement_justificatifs.j2", formsemestre=formsemestre, sco=ScoData(formsemestre=formsemestre), lignes=lignes, ) @bp.route("signal_assiduites_hebdo") @scodoc @permission_required(Permission.ScoView) def signal_assiduites_hebdo(): """ signal_assiduites_hebdo paramètres obligatoires : - formsemestre_id : id du formsemestre - groups_id : id des groupes (séparés par des virgules -> 1,2,3) paramètres optionnels : - week : date semaine (iso 8601 -> 20XX-WXX), par défaut la semaine actuelle - moduleimpl_id : id du moduleimpl (par défaut None) Permissions : - ScoView -> page en lecture seule - AbsChange -> page en lecture/écriture """ # Récupération des paramètres moduleimpl_id: int = request.args.get("moduleimpl_id", None) group_ids: str = request.args.get("group_ids", "") # ex: "1,2,3" formsemestre_id: int = request.args.get("formsemestre_id", -1) week: str = request.args.get("week", datetime.datetime.now().strftime("%G-W%V")) # Vérification des paramètres if group_ids == "" or formsemestre_id == -1: raise ScoValueError("Paramètres manquants", dest_url=request.referrer) # Récupération du moduleimpl try: moduleimpl_id: int = int(moduleimpl_id) except (ValueError, TypeError): moduleimpl_id: str | None = None if moduleimpl_id != "autre" else moduleimpl_id # Récupération du formsemestre formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) # Vérification semaine dans format iso 8601 et formsemestre regex_iso8601 = r"^\d{4}-W\d{2}$" if week and not re.match(regex_iso8601, week): raise ScoValueError("Semaine invalide", dest_url=request.referrer) fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W") fs_fin_iso8601 = formsemestre.date_fin.strftime("%Y-W%W") # Utilisation de la propriété de la norme iso 8601 # les chaines sont triables par ordre alphanumérique croissant # et produiront le même ordre que les dates par ordre chronologique croissant if (not week) or week < fs_deb_iso8601 or week > fs_fin_iso8601: if week: flash( """La semaine n'est pas dans le semestre, choisissez la semaine sur laquelle saisir l'assiduité""" ) return sco_gen_cal.calendrier_choix_date( date_debut=formsemestre.date_debut, date_fin=formsemestre.date_fin, url=url_for( "assiduites.signal_assiduites_hebdo", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, group_ids=group_ids, moduleimpl_id=moduleimpl_id, week="placeholder", ), mode="semaine", titre="Choix de la semaine", ) # Vérification des groupes group_ids = group_ids.split(",") if group_ids != "" else [] groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True ) if not groups_infos.members: return ( html_sco_header.sco_header(page_title="Assiduité: saisie hebdomadaire") + "

Aucun étudiant !

" + html_sco_header.sco_footer() ) # Récupération des étudiants etudiants: list[Identite] = [ Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members ] if groups_infos.tous_les_etuds_du_sem: gr_tit = "en" else: if len(groups_infos.group_ids) > 1: grp = "des groupes" else: grp = "du groupe" gr_tit = ( grp + ' ' + groups_infos.groups_titles + "" ) # Gestion des jours jours: dict[str, list[str]] = { "lun": [ "Lundi", datetime.datetime.strptime(week + "-1", "%G-W%V-%u").strftime("%d/%m/%Y"), ], "mar": [ "Mardi", datetime.datetime.strptime(week + "-2", "%G-W%V-%u").strftime("%d/%m/%Y"), ], "mer": [ "Mercredi", datetime.datetime.strptime(week + "-3", "%G-W%V-%u").strftime("%d/%m/%Y"), ], "jeu": [ "Jeudi", datetime.datetime.strptime(week + "-4", "%G-W%V-%u").strftime("%d/%m/%Y"), ], "ven": [ "Vendredi", datetime.datetime.strptime(week + "-5", "%G-W%V-%u").strftime("%d/%m/%Y"), ], "sam": [ "Samedi", datetime.datetime.strptime(week + "-6", "%G-W%V-%u").strftime("%d/%m/%Y"), ], "dim": [ "Dimanche", datetime.datetime.strptime(week + "-7", "%G-W%V-%u").strftime("%d/%m/%Y"), ], } non_travail = sco_preferences.get_preference("non_travail") non_travail = non_travail.replace(" ", "").split(",") hebdo_jours: list[tuple[bool, str]] = [] for key, val in jours.items(): hebdo_jours.append((key in non_travail, val)) url_choix_semaine = url_for( "assiduites.signal_assiduites_hebdo", group_ids=",".join(map(str, groups_infos.group_ids)), week="", scodoc_dept=g.scodoc_dept, formsemestre_id=groups_infos.formsemestre_id, moduleimpl_id=moduleimpl_id, ) return render_template( "assiduites/pages/signal_assiduites_hebdo.j2", title="Assiduité: saisie hebdomadaire", gr=gr_tit, etudiants=etudiants, moduleimpl_select=_module_selector( formsemestre=formsemestre, moduleimpl_id=moduleimpl_id ), hebdo_jours=hebdo_jours, readonly=not current_user.has_permission(Permission.AbsChange), non_present=sco_preferences.get_preference( "non_present", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ), url_choix_semaine=url_choix_semaine, ) @bp.route("edit_assiduite_etud/", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) def edit_assiduite_etud(assiduite_id: int): """ Page affichant les détails d'une assiduité Si le current_user alors la page propose un formulaire de modification """ try: assi: Assiduite = Assiduite.get_assiduite(assiduite_id=assiduite_id) except HTTPException: flash("Assiduité invalide") return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept)) etud: Identite = assi.etudiant formsemestre: FormSemestre = assi.get_formsemestre() readonly: bool = not current_user.has_permission(Permission.AbsChange) form: EditAssiForm = EditAssiForm(request.form) if readonly: form.disable_all() # peuplement moduleimpl_select choices: OrderedDict = OrderedDict() choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")] # Récupération des modulesimpl du semestre si existant. if formsemestre: # indique le nom du semestre dans le menu (optgroup) modimpls_from_formsemestre = etud.get_modimpls_from_formsemestre(formsemestre) group_name: str = formsemestre.titre_annee() choices[group_name] = [ (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}") for m in modimpls_from_formsemestre if m.module.ue.type == UE_STANDARD ] choices.move_to_end("", last=False) form.modimpl.choices = choices # Vérification formulaire if form.validate_on_submit(): if form.cancel.data: # cancel button return redirect(request.referrer) # vérification des valeurs # Gestion de l'état etat = form.assi_etat.data try: etat = int(etat) etat = scu.EtatAssiduite.inverse().get(etat, None) except ValueError: etat = None if etat is None: form.error_messages.append("État invalide") form.ok = False description = form.description.data or "" description = description.strip() moduleimpl_id = form.modimpl.data if form.modimpl.data is not None else -1 # Vérifications des dates / horaires ok, dt_deb, dt_fin, dt_entry = _get_dates_from_assi_form( form, etud, from_justif=True, formsemestre=formsemestre ) if ok: if is_period_conflicting( dt_deb, dt_fin, etud.assiduites, Assiduite, assi.id ): form.set_error("La période est en conflit avec une autre assiduité") form.ok = False if form.ok: assi.etat = etat assi.description = description if moduleimpl_id != -1: assi.set_moduleimpl(moduleimpl_id) assi.date_debut = dt_deb assi.date_fin = dt_fin assi.entry_date = dt_entry db.session.add(assi) db.session.commit() scass.simple_invalidate_cache(assi.to_dict(format_api=True), assi.etudid) flash("enregistré") return redirect(request.referrer) # Remplissage du formulaire form.assi_etat.data = str(assi.etat) form.description.data = assi.description moduleimpl_id: int | str | None = assi.get_moduleimpl_id() or "" form.modimpl.data = str(moduleimpl_id) form.date_debut.data = assi.date_debut.strftime(scu.DATE_FMT) form.heure_debut.data = assi.date_debut.strftime(scu.TIME_FMT) form.date_fin.data = assi.date_fin.strftime(scu.DATE_FMT) form.heure_fin.data = assi.date_fin.strftime(scu.TIME_FMT) form.entry_date.data = assi.entry_date.strftime(scu.DATE_FMT) form.entry_time.data = assi.entry_date.strftime(scu.TIME_FMT) return render_template( "assiduites/pages/edit_assiduite_etud.j2", etud=etud, sco=ScoData(etud, formsemestre=formsemestre), form=form, readonly=readonly, objet=_preparer_objet("assiduite", assi), title=f"Assiduité {etud.nom_short}", ) def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str: """Génère la liste des assiduités d'un étudiant pour le bulletin mail""" # On récupère la métrique d'assiduité metrique: str = scu.translate_assiduites_metric( sco_preferences.get_preference("assi_metrique", formsemestre_id=semestre.id), ) # On récupère le nombre maximum de ligne d'assiduité max_nb: int = int( sco_preferences.get_preference( "bul_mail_list_abs_nb", formsemestre_id=semestre.id ) ) # On récupère les assiduités et les justificatifs de l'étudiant assiduites = scass.filter_by_formsemestre( etud.assiduites, Assiduite, semestre ).order_by(Assiduite.entry_date.desc()) justificatifs = scass.filter_by_formsemestre( etud.justificatifs, Justificatif, semestre ).order_by(Justificatif.entry_date.desc()) # On calcule les statistiques stats: dict = scass.get_assiduites_stats( assiduites, metric=metrique, filtered={"split": True} ) # On sépare : # - abs_j = absences justifiées # - abs_nj = absences non justifiées # - retards = les retards # - justifs = les justificatifs abs_j: list[str] = [ {"date": _get_date_str(assi.date_debut, assi.date_fin)} for assi in assiduites if assi.etat == scu.EtatAssiduite.ABSENT and assi.est_just is True ] abs_nj: list[str] = [ {"date": _get_date_str(assi.date_debut, assi.date_fin)} for assi in assiduites if assi.etat == scu.EtatAssiduite.ABSENT and assi.est_just is False ] retards: list[str] = [ {"date": _get_date_str(assi.date_debut, assi.date_fin)} for assi in assiduites if assi.etat == scu.EtatAssiduite.RETARD ] justifs: list[dict[str, str]] = [ { "date": _get_date_str(justi.date_debut, justi.date_fin), "raison": "" if justi.raison is None else justi.raison, "etat": { scu.EtatJustificatif.VALIDE: "justificatif valide", scu.EtatJustificatif.NON_VALIDE: "justificatif invalide", scu.EtatJustificatif.ATTENTE: "justificatif en attente de validation", scu.EtatJustificatif.MODIFIE: "justificatif ayant été modifié", }.get(justi.etat), } for justi in justificatifs ] return render_template( "assiduites/widgets/liste_assiduites_mail.j2", abs_j=abs_j[:max_nb], abs_nj=abs_nj[:max_nb], retards=retards[:max_nb], justifs=justifs[:max_nb], stats=stats, metrique=scu.translate_assiduites_metric(metrique, short=True, inverse=False), metric=metrique, ) # --- Fonctions internes --- def _dateiso_to_datefr(date_iso: str) -> str: """ _dateiso_to_datefr Transforme une date iso en date format français Args: date_iso (str): date au format iso (YYYY-MM-DD) Raises: ValueError: Si l'argument `date_iso` n'est pas au bon format Returns: str: date au format français (DD/MM/YYYY) """ regex_date_iso: str = r"^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])$" # Vérification de la date_iso if not re.match(regex_date_iso, date_iso): raise ValueError( f"La dateiso passée en paramètre [{date_iso}] n'est pas valide." ) return f"{date_iso[8:10]}/{date_iso[5:7]}/{date_iso[0:4]}" def _get_date_str(deb: datetime.datetime, fin: datetime.datetime) -> str: """ _get_date_str transforme une période en chaîne lisible Args: deb (datetime.datetime): date de début fin (datetime.datetime): date de fin Returns: str: "le dd/mm/yyyy de hh:MM à hh:MM" si les deux date sont sur le même jour "du dd/mm/yyyy hh:MM audd/mm/yyyy hh:MM" sinon """ if deb.date() == fin.date(): temps = deb.strftime("%d/%m/%Y %H:%M").split(" ") + [fin.strftime(scu.TIME_FMT)] return f"le {temps[0]} de {temps[1]} à {temps[2]}" return f'du {deb.strftime("%d/%m/%Y %H:%M")} au {fin.strftime("%d/%m/%Y %H:%M")}' def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> str: """ _module_selector Génère un HTMLSelectElement à partir des moduleimpl du formsemestre Args: formsemestre (FormSemestre): Le formsemestre d'où les moduleimpls seront pris. Returns: str: La représentation str d'un HTMLSelectElement """ # récupération des ues du semestre ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) modimpls_list: list[dict] = ntc.get_modimpls_dict() # prévoie la sélection par défaut d'un moduleimpl s'il a été passé en paramètre selected = "" if moduleimpl_id is not None else "selected" modules: list[dict[str, str | int]] = [] # Récupération de l'id et d'un nom lisible pour chaque moduleimpl for modimpl in modimpls_list: modname: str = ( (modimpl["module"]["code"] or "") + " " + (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "") ) modules.append({"moduleimpl_id": modimpl["moduleimpl_id"], "name": modname}) return render_template( "assiduites/widgets/moduleimpl_selector.j2", formsemestre_id=formsemestre.id, modules=modules, moduleimpl_id=moduleimpl_id, selected=selected, ) def _module_selector_multiple( etud: Identite, moduleimpl_id: int = None, only_form: FormSemestre = None, no_default: bool = False, annee_sco: int | None = None, ) -> str: """menu HTML