# -*- 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 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, ChoixDateForm, ) 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 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.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) # Gestion évaluations (appel à la page depuis les évaluations) evaluation_id: int | None = request.args.get("evaluation_id") saisie_eval = evaluation_id is not None moduleimpl_id: int | None = request.args.get("moduleimpl_id", "") redirect_url: str = ( "#" if not saisie_eval else url_for( "notes.evaluation_check_absences_html", evaluation_id=evaluation_id, scodoc_dept=g.scodoc_dept, ) ) form = AjoutAssiduiteEtudForm(request.form) # On dresse la liste des modules de l'année scolaire en cours # auxquels est inscrit l'étudiant pour peupler le menu "module" modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) choices: OrderedDict = OrderedDict() choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")] for formsemestre_id in modimpls_by_formsemestre: formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) # indique le nom du semestre dans le menu (optgroup) group_name: str = formsemestre.titre_annee() choices[group_name] = [ (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}") for m in modimpls_by_formsemestre[formsemestre_id] if m.module.ue.type == UE_STANDARD ] if formsemestre.est_courant(): choices.move_to_end(group_name, last=False) 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) 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), tableau=tableau, scu=scu, ) def _get_dates_from_assi_form( form: AjoutAssiOrJustForm, from_justif: bool = False, ) -> 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) 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, ) -> 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) # Le module (avec "autre") mod_data = form.modimpl.data if mod_data: if mod_data == "autre": moduleimpl_id = "autre" else: try: moduleimpl_id = int(mod_data) except ValueError: form.modimpl.error("choix de module invalide") ok = False else: moduleimpl_id = None if not ok: return False external_data = None moduleimpl: ModuleImpl | None = None match moduleimpl_id: case "autre": external_data = {"module": "Autre"} case None: moduleimpl = None case _: moduleimpl = ModuleImpl.query.get(moduleimpl_id) try: assi_etat: scu.EtatAssiduite = scu.EtatAssiduite.get(form.assi_etat.data) ass = Assiduite.create_assiduite( etud, dt_debut_tz_server, dt_fin_tz_server, assi_etat, description=form.description.data, entry_date=dt_entry_date_tz_server, external_data=external_data, moduleimpl=moduleimpl, notify_mail=True, user_id=current_user.id, ) db.session.add(ass) db.session.commit() if assi_etat != scu.EtatAssiduite.PRESENT and form.est_just.data: # si la case "justifiée est cochée alors on créé un justificatif de même période" justi: Justificatif = Justificatif.create_justificatif( etudiant=etud, date_debut=dt_debut_tz_server, date_fin=dt_fin_tz_server, etat=scu.EtatJustificatif.VALIDE, user_id=current_user.id, ) # On met à jour les assiduités en fonction du nouveau justificatif justi.justifier_assiduites() # Invalider cache scass.simple_invalidate_cache(ass.to_dict(), etud.id) return True except ScoValueError as exc: err: str = f"Erreur: {exc.args[0]}" if ( exc.args[0] == "Duplication: la période rentre en conflit avec une plage enregistrée" ): # Récupération de la première assiduité conflictuelle conflits: Query = etud.assiduites.filter( Assiduite.date_debut < dt_fin_tz_server, Assiduite.date_fin > dt_debut_tz_server, ) assi: Assiduite = conflits.first() lien: str = url_for( "assiduites.tableau_assiduite_actions", type="assiduite", action="details", obj_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("/liste_assiduites_etud") @scodoc @permission_required(Permission.ScoView) def liste_assiduites_etud(): """ liste_assiduites_etud Affichage de toutes les assiduites et justificatifs d'un etudiant Args: etudid (int): l'identifiant de l'étudiant Returns: str: l'html généré """ # Récupération de l'étudiant concerné etudid = request.args.get("etudid", -1) etud: Identite = Identite.query.get_or_404(etudid) if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") # Gestion d'une assiduité unique (redirigé depuis le calendrier) TODO-Assiduites assiduite_id: int = request.args.get("assiduite_id", -1) # Préparation de la page tableau = _prepare_tableau( liste_assi.AssiJustifData.from_etudiants( etud, ), filename=f"assiduites-justificatifs-{etud.id}", afficher_etu=False, filtre=liste_assi.AssiFiltre(type_obj=0), options=liste_assi.AssiDisplayOptions(show_module=True), cache_key=f"tableau-etud-{etud.id}", ) if not tableau[0]: return tableau[1] # Page HTML: return render_template( "assiduites/pages/liste_assiduites.j2", assi_id=assiduite_id, etud=etud, tableau=tableau[1], sco=ScoData(etud), ) @bp.route("/bilan_etud") @scodoc @permission_required(Permission.ScoView) def bilan_etud(): """ bilan_etud Affichage de toutes les assiduites et justificatifs d'un etudiant Args: etudid (int): l'identifiant de l'étudiant Returns: str: l'html généré """ # Récupération de l'étudiant etudid = request.args.get("etudid", -1) etud: Identite = Identite.query.get_or_404(etudid) if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") # Gestion des dates du bilan (par défaut l'année scolaire) date_debut = scu.date_debut_annee_scolaire().strftime(scu.DATE_FMT) date_fin: str = scu.date_fin_annee_scolaire().strftime(scu.DATE_FMT) # Récupération de la métrique d'assiduité assi_metric = scu.translate_assiduites_metric( sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id), ) # Récupération des assiduités et justificatifs de l'étudiant data = liste_assi.AssiJustifData( etud.assiduites.filter( Assiduite.etat != scu.EtatAssiduite.PRESENT, Assiduite.est_just == False ), etud.justificatifs.filter( Justificatif.etat.in_( [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE] ) ), ) table = _prepare_tableau( data, afficher_etu=False, filename=f"Bilan assiduité {etud.nomprenom}", titre="Bilan de l'assiduité de l'étudiant", cache_key=f"tableau-etud-{etud.id}-bilan", ) if not table[0]: return table[1] # Génération de la page HTML return render_template( "assiduites/pages/bilan_etud.j2", assi_metric=assi_metric, assi_seuil=_get_seuil(), date_debut=date_debut, date_fin=date_fin, sco=ScoData(etud), tableau=table[1], ) @bp.route("/edit_justificatif_etud/", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def edit_justificatif_etud(justif_id: int): """ Edition d'un justificatif. Il faut de plus la permission pour voir/modifier la raison. Args: justif_id (int): l'identifiant du justificatif Returns: str: l'html généré """ justif = Justificatif.get_justificatif(justif_id) form = AjoutJustificatifEtudForm(obj=justif) # Set the default value for the etat field if request.method == "GET": form.date_debut.data = justif.date_debut.strftime(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.liste_assiduites_etud", scodoc_dept=g.scodoc_dept, etudid=justif.etudiant.id, ) if form.validate_on_submit(): if form.cancel.data: # 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=justif, nb_files=nb_files, title=f"Modification justificatif absence de {justif.etudiant.html_link_fiche()}", redirect_url=redirect_url, sco=ScoData(justif.etudiant), scu=scu, ) @bp.route("/ajout_justificatif_etud", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def ajout_justificatif_etud(): """ ajout_justificatif_etud : Affichage et création des justificatifs de l'étudiant Args: etudid (int): l'identifiant de l'étudiant Returns: str: l'html généré """ etud = Identite.get_etud(request.args.get("etudid")) redirect_url = url_for( "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ) form = AjoutJustificatifEtudForm() if form.validate_on_submit(): if form.cancel.data: # cancel button return redirect(redirect_url) ok = _record_justificatif_etud(etud, form) if ok: return redirect(redirect_url) is_html, tableau = _prepare_tableau( liste_assi.AssiJustifData.from_etudiants( etud, ), filename=f"justificatifs-{etud.nom or ''}", afficher_etu=False, filtre=liste_assi.AssiFiltre(type_obj=2), options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True), afficher_options=False, titre="Justificatifs enregistrés pour cet étudiant", cache_key=f"tableau-etud-{etud.id}", ) if not is_html: return tableau return render_template( "assiduites/pages/ajout_justificatif_etud.j2", etud=etud, form=form, title=f"Ajout justificatif absence pour {etud.html_link_fiche()}", redirect_url=redirect_url, sco=ScoData(etud), scu=scu, tableau=tableau, ) def _verif_date_form_justif( form: AjoutJustificatifEtudForm, deb: datetime.datetime, fin: datetime.datetime ) -> tuple[datetime.datetime, datetime.datetime]: """Gère les cas suivants : - si on indique seulement une date de debut : journée 0h-23h59 - si on indique date de debut et heures : journée +heure deb/fin (déjà géré par _get_dates_from_assi_form) - Si on indique une date de début et de fin sans heures : Journées 0h-23h59 - Si on indique une date de début et de fin avec heures : On fait un objet avec datedeb/heuredeb + datefin/heurefin (déjà géré par _get_dates_from_assi_form) """ cas: list[bool] = [ # cas 1 not form.date_fin.data and not form.heure_debut.data, # cas 3 form.date_fin.data != "" and not form.heure_debut.data, ] if any(cas): deb = deb.replace(hour=0, minute=0) fin = fin.replace(hour=23, minute=59) print(f"DEBUG {cas=}") return deb, fin 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, from_justif=True) if not ok: log("_record_justificatif_etud: dates invalides") form.set_error("Erreur: dates invalides") return False if not form.etat.data: log("_record_justificatif_etud: etat invalide") form.set_error("Erreur: état invalide") return False etat = int(form.etat.data) if not scu.EtatJustificatif.is_valid_etat(etat): log(f"_record_justificatif_etud: etat invalide ({etat})") form.set_error("Erreur: état invalide") return False try: message = "" if justif: form.date_debut.data = dt_debut_tz_server form.date_fin.data = dt_fin_tz_server form.entry_date.data = dt_entry_date_tz_server justif.dejustifier_assiduites() if justif.edit_from_form(form): message = "Justificatif modifié" # On met à jour la db pour avoir les bonnes donnés pour le journal etud db.session.add(justif) db.session.commit() Scolog.logdb( method="edit_justificatif", etudid=etud.id, msg=f"justificatif modif: {justif}", ) else: message = "Pas de modification" fichier_suppr: list[str] = request.form.getlist("suppr_fichier_just") if len(fichier_suppr) > 0 and justif.fichier is not None: archiver: JustificatifArchiver = JustificatifArchiver() for fichier in fichier_suppr: archiver.delete_justificatif(etud, justif.fichier, fichier) flash(f"Fichier {fichier} supprimé") else: justif = Justificatif.create_justificatif( etud, dt_debut_tz_server, dt_fin_tz_server, etat=etat, raison=form.raison.data, entry_date=dt_entry_date_tz_server, user_id=current_user.id, ) message = "Justificatif créé" db.session.add(justif) if not _upload_justificatif_files(justif, form): flash("Erreur enregistrement fichiers") log("problem in _upload_justificatif_files, rolling back") db.session.rollback() return False db.session.commit() justif.justifier_assiduites() scass.simple_invalidate_cache(justif.to_dict(), etud.id) flash(message) return True except ScoValueError as exc: log(f"_record_justificatif_etud: erreur {exc.args[0]}") db.session.rollback() form.set_error(f"Erreur: {exc.args[0]}") return False def _upload_justificatif_files( just: Justificatif, form: AjoutJustificatifEtudForm ) -> bool: """Enregistre les fichiers du formulaire de création de justificatif""" # Utilisation de l'archiver de justificatifs archiver: JustificatifArchiver = JustificatifArchiver() archive_name: str = just.fichier try: # On essaye de sauvegarder les fichiers for file in form.fichiers.data or []: archive_name, _ = archiver.save_justificatif( just.etudiant, filename=file.filename, data=file.stream.read(), archive_name=archive_name, user_id=current_user.id, ) flash(f"Fichier {file.filename} enregistré") if form.fichiers.data: # On actualise l'archive du justificatif just.fichier = archive_name db.session.add(just) db.session.commit() return True except ScoValueError as exc: log( f"_upload_justificatif_files: error on {file.filename} for etud {just.etudid}" ) form.set_error(f"Erreur sur fichier justificatif: {exc.args[0]}") return False @bp.route("/calendrier_assi_etud") @scodoc @permission_required(Permission.ScoView) def calendrier_assi_etud(): """ Affichage d'un calendrier de l'assiduité de l'étudiant Args: etudid (int): l'identifiant de l'étudiant Returns: str: l'html généré """ # Récupération de l'étudiant etudid = request.args.get("etudid", -1) etud: Identite = Identite.query.get_or_404(etudid) if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") # Options mode_demi: bool = scu.to_bool(request.args.get("mode_demi", "t")) show_pres: bool = scu.to_bool(request.args.get("show_pres", "f")) show_reta: bool = scu.to_bool(request.args.get("show_reta", "f")) annee: int = int(request.args.get("annee", scu.annee_scolaire())) # Récupération des années d'étude de l'étudiant annees: list[int] = [] for ins in etud.formsemestre_inscriptions: date_deb = ins.formsemestre.date_debut date_fin = ins.formsemestre.date_fin annees.extend( [ scu.annee_scolaire_repr(date_deb.year, date_deb.month), scu.annee_scolaire_repr(date_fin.year, date_fin.month), ] ) annees = sorted(annees, reverse=True) # Transformation en une liste "json" # (sera utilisé pour générer le selecteur d'année) annees_str: str = json.dumps(annees) calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee) # Peuplement du template jinja return render_template( "assiduites/pages/calendrier_assi_etud.j2", sco=ScoData(etud), annee=annee, nonworkdays=_non_work_days(), annees=annees_str, calendrier=calendrier, mode_demi=mode_demi, show_pres=show_pres, show_reta=show_reta, ) @bp.route("/choix_date", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def choix_date() -> str: """ choix_date Choix de la date pour la saisie des assiduités Route utilisée uniquement si la date courante n'est pas dans le semestre concerné par la requête vers une des pages suivantes : - saisie_assiduites_group - visu_assiduites_group """ formsemestre_id = request.args.get("formsemestre_id") formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) group_ids = request.args.get("group_ids") moduleimpl_id = request.args.get("moduleimpl_id") form = ChoixDateForm(request.form) if form.validate_on_submit(): if form.cancel.data: return redirect(url_for("scodoc.index")) # Vérifier si date dans semestre ok: bool = False try: date: datetime.date = datetime.datetime.strptime( form.date.data, scu.DATE_FMT ).date() if date < formsemestre.date_debut or date > formsemestre.date_fin: form.set_error( "La date sélectionnée n'est pas dans le semestre.", form.date ) else: ok = True except ValueError: form.set_error("Date invalide", form.date) if ok: return redirect( url_for( ( "assiduites.signal_assiduites_group" if request.args.get("readonly") is None else "assiduites.visu_assiduites_group" ), scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, group_ids=group_ids, moduleimpl_id=moduleimpl_id, jour=date.isoformat(), ) ) return render_template( "assiduites/pages/choix_date.j2", form=form, sco=ScoData(formsemestre=formsemestre), deb=formsemestre.date_debut.strftime(scu.DATE_FMT), fin=formsemestre.date_fin.strftime(scu.DATE_FMT), ) @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("jour", 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 journalière 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 return redirect( url_for( "assiduites.choix_date", formsemestre_id=formsemestre_id, group_ids=group_ids, moduleimpl_id=moduleimpl_id, scodoc_dept=g.scodoc_dept, ) ) # --- 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 journalière des assiduités", ) @bp.route("/visu_assiduites_group") @scodoc @permission_required(Permission.ScoView) def visu_assiduites_group(): """ Visualisation des assiduités des groupes pour le jour donné dans le formsemestre_id et le moduleimpl_id Returns: str: l'html généré """ # Récupération des paramètres de la requête formsemestre_id: int = request.args.get("formsemestre_id", -1) moduleimpl_id: int = request.args.get("moduleimpl_id") date: str = request.args.get("jour", datetime.date.today().isoformat()) group_ids: list[int] = request.args.get("group_ids", None) if group_ids is None: group_ids = [] else: group_ids = group_ids.split(",") map(str, group_ids) # Vérification du moduleimpl_id if moduleimpl_id is not None: try: moduleimpl_id = int(moduleimpl_id) except (TypeError, ValueError) as exc: raise ScoValueError("identifiant de moduleimpl invalide") from exc # Vérification du formsemestre_id if formsemestre_id is not None: try: formsemestre_id = int(formsemestre_id) except (TypeError, ValueError) as exc: raise ScoValueError("identifiant de formsemestre invalide") from exc # Récupérations des/du groupe(s) groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id ) if not groups_infos.members: return ( html_sco_header.sco_header(page_title="Saisie journalière 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 du/des groupe(s) etuds = [ sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] for m in groups_infos.members ] # --- Vérification de la date --- real_date = scu.is_iso_formated(date, True).date() if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin: # Si le jour est hors semestre, renvoyer vers choix date return redirect( url_for( "assiduites.choix_date", formsemestre_id=formsemestre_id, group_ids=group_ids, moduleimpl_id=moduleimpl_id, scodoc_dept=g.scodoc_dept, readonly="true", ) ) # --- Restriction en fonction du moduleimpl_id --- if moduleimpl_id: mod_inscrits = { x["etudid"] for x in sco_moduleimpl.do_moduleimpl_inscription_list( moduleimpl_id=moduleimpl_id ) } etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits] if etuds_inscrits_module: etuds = etuds_inscrits_module else: # Si aucun etudiant n'est inscrit au module choisi... moduleimpl_id = None # --- Génération du HTML --- 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() 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, ), formsemestre_date_debut=str(formsemestre.date_debut), formsemestre_date_fin=str(formsemestre.date_fin), formsemestre_id=formsemestre_id, gr_tit=gr_tit, grp=sco_groups_view.menu_groups_choice(groups_infos), minitimeline=_mini_timeline(), moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), nonworkdays=_non_work_days(), sem=sem["titre_num"], timeline=_timeline(), readonly="true", sco=ScoData(formsemestre=formsemestre), title="Saisie journalière de l'assiduité", ) 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 _prepare_tableau( data: liste_assi.AssiJustifData, filename: str = "tableau-assiduites", afficher_etu: bool = True, filtre: liste_assi.AssiFiltre = None, options: liste_assi.AssiDisplayOptions = None, afficher_options: bool = True, titre="Évènements enregistrés pour cet étudiant", cache_key: str = "", force_options: dict[str, object] = None, ) -> tuple[bool, Response | str]: """ Prépare un tableau d'assiduités / justificatifs Cette fonction récupère dans la requête les arguments : show_pres : bool -> Affiche les présences, par défaut False show_reta : bool -> Affiche les retard, par défaut False show_desc : bool -> Affiche les descriptions, par défaut False Returns: tuple[bool | Reponse|str ]: - bool : Vrai si la réponse est du Text/HTML - Reponse : du Text/HTML ou Une Reponse (téléchargement fichier) """ show_pres: bool | str = request.args.get("show_pres", False) show_reta: bool | str = request.args.get("show_reta", False) show_desc: bool | str = request.args.get("show_desc", False) nb_ligne_page: int = request.args.get("nb_ligne_page") # Vérification de nb_ligne_page try: nb_ligne_page: int = int(nb_ligne_page) except (ValueError, TypeError): nb_ligne_page = liste_assi.ListeAssiJusti.NB_PAR_PAGE page_number: int = request.args.get("n_page", 1) # Vérification de page_number try: page_number: int = int(page_number) except (ValueError, TypeError): page_number = 1 fmt = request.args.get("fmt", "html") annee_sco: str | None = request.args.get("annee_sco", None) # Vérification de l'année scolaire if annee_sco is not None: try: annee_sco = int(annee_sco) except (ValueError, TypeError): annee_sco = None # Ordre ordre: tuple[str, str | bool] = None ordre_col: str = request.args.get("order_col", None) ordre_tri: str = request.args.get("order", None) if ordre_col is not None and ordre_tri is not None: ordre = (ordre_col, ordre_tri == "ascending") if options is None: options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions() options.remplacer( page=page_number, nb_ligne_page=nb_ligne_page, show_pres=show_pres, show_reta=show_reta, show_desc=show_desc, show_etu=afficher_etu, order=ordre, annee_sco=annee_sco, ) if force_options is not None: options.remplacer(**force_options) table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( table_data=data, options=options, filtre=filtre, no_pagination=fmt.startswith("xls"), titre=cache_key, ) if fmt.startswith("xls"): return False, scu.send_file( table.excel(), filename=filename, mime=scu.XLSX_MIMETYPE, suffix=scu.XLSX_SUFFIX, ) return True, render_template( "assiduites/widgets/tableau.j2", table=table, total_pages=table.total_pages, options=options, afficher_options=afficher_options, titre=titre, ) @bp.route("/recup_assiduites_plage", methods=["POST"]) @scodoc @permission_required(Permission.AbsChange) def recup_assiduites_plage(): """ Renvoie un fichier excel contenant toutes les assiduités d'une plage La plage est définie par les valeurs "datedeb" et "datefin" du formulaire Par défaut tous les étudiants du département sont concernés Si le champs "formsemestre_id" est présent dans le formulaire et est non vide, seuls les étudiants inscrits dans ce semestre sont concernés. """ date_deb: datetime.datetime = request.form.get("datedeb") date_fin: datetime.datetime = request.form.get("datefin") # Vérification des dates try: date_deb = datetime.datetime.strptime(date_deb, scu.DATE_FMT) except ValueError as exc: raise ScoValueError("date_debut invalide", dest_url=request.referrer) from exc try: date_fin = datetime.datetime.strptime(date_fin, scu.DATE_FMT) except ValueError as exc: raise ScoValueError("date_fin invalide", dest_url=request.referrer) from exc # Récupération des étudiants etuds: Query = [] formsemestre_id: str | None = request.form.get("formsemestre_id") name: str = "" if formsemestre_id is not None and formsemestre_id != "": formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) etuds = formsemestre.etuds name = formsemestre.session_id() else: dept: Departement = Departement.query.get_or_404(g.scodoc_dept_id) etuds = dept.etudiants name = dept.acronym # Récupération des assiduités/justificatifs etudids: list[int] = [etud.id for etud in etuds] assiduites: Query = Assiduite.query.filter(Assiduite.etudid.in_(etudids)) justificatifs: Query = Justificatif.query.filter(Justificatif.etudid.in_(etudids)) # Filtrage des assiduités/justificatifs en fonction des dates données assiduites = scass.filter_by_date(assiduites, Assiduite, date_deb, date_fin) justificatifs = scass.filter_by_date( justificatifs, Justificatif, date_deb, date_fin ) table_data: liste_assi.AssiJustifData = liste_assi.AssiJustifData( assiduites_query=assiduites, justificatifs_query=justificatifs, ) options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions( show_pres=True, show_reta=True, show_module=True, show_desc=True, show_etu=True, annee_sco=-1, ) date_deb_str: str = date_deb.strftime("%d-%m-%Y") date_fin_str: str = date_fin.strftime("%d-%m-%Y") filename: str = f"assiduites_{name}_{date_deb_str}_{date_fin_str}" tableau: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( table_data, options=options, titre="tableau-dept-" + filename, no_pagination=True, ) return scu.send_file( tableau.excel(), filename=filename, mime=scu.XLSX_MIMETYPE, suffix=scu.XLSX_SUFFIX, ) @bp.route("/tableau_assiduite_actions", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def tableau_assiduite_actions(): """Edition/suppression/information sur une assiduité ou un justificatif type = "assiduite" | "justificatif" action = "supprimer" | "details" | "justifier" """ obj_type: str = request.args.get("type", "assiduite") action: str = request.args.get("action", "details") obj_id: str = int(request.args.get("obj_id", -1)) objet: Assiduite | Justificatif objet_name = "" if obj_type == "assiduite": objet: Assiduite = Assiduite.query.get_or_404(obj_id) objet_name = scu.EtatAssiduite(objet.etat).version_lisible() else: objet: Justificatif = Justificatif.query.get_or_404(obj_id) objet_name = "Justificatif" # Suppression : attention, POST ou GET ! if action == "supprimer": objet.supprime() flash(f"{objet_name} supprimé") return redirect(request.referrer) # Justification d'une assiduité depuis le tableau if action == "justifier" and obj_type == "assiduite": # Création du justificatif correspondant justificatif_correspondant: Justificatif = Justificatif.create_justificatif( etudiant=objet.etudiant, date_debut=objet.date_debut, date_fin=objet.date_fin, etat=scu.EtatJustificatif.VALIDE, user_id=current_user.id, ) justificatif_correspondant.justifier_assiduites() scass.simple_invalidate_cache( justificatif_correspondant.to_dict(), objet.etudiant.id ) flash(f"{objet_name} justifiée") return redirect(request.referrer) if request.method == "GET": module: str | int = "" # moduleimpl_id ou chaine libre if obj_type == "assiduite": # Construction du menu module module = _module_selector_multiple(objet.etudiant, objet.moduleimpl_id) return render_template( "assiduites/pages/tableau_assiduite_actions.j2", action=action, can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView) or (obj_type == "justificatif" and current_user.id == objet.user_id), etud=objet.etudiant, moduleimpl=module, obj_id=obj_id, objet_name=objet_name, objet=_preparer_objet(obj_type, objet), sco=ScoData(etud=objet.etudiant), title=f"Assiduité {objet.etudiant.nom_short}", # type utilisé dans les actions modifier / détails (modifier.j2, details.j2) type="Justificatif" if obj_type == "justificatif" else "Assiduité", ) # ----- Cas POST if obj_type == "assiduite": try: _action_modifier_assiduite(objet) except ScoValueError as error: raise ScoValueError(error.args[0], request.referrer) from error flash("L'assiduité a bien été modifiée.") else: try: _action_modifier_justificatif(objet) except ScoValueError as error: raise ScoValueError(error.args[0], request.referrer) from error flash("Le justificatif a bien été modifié.") return redirect(request.form["table_url"]) def _action_modifier_assiduite(assi: Assiduite): form = request.form # Gestion de l'état etat = scu.EtatAssiduite.get(form["etat"]) if etat is not None: assi.etat = etat if etat == scu.EtatAssiduite.PRESENT: assi.est_just = False else: assi.est_just = len(get_assiduites_justif(assi.assiduite_id, False)) > 0 # Gestion de la description assi.description = form["description"] possible_moduleimpl_id: str = form["moduleimpl_select"] # Raise ScoValueError (si None et force module | Etudiant non inscrit | Module non reconnu) assi.set_moduleimpl(possible_moduleimpl_id) db.session.add(assi) db.session.commit() scass.simple_invalidate_cache(assi.to_dict(True), assi.etudid) def _action_modifier_justificatif(justi: Justificatif): "Modifie le justificatif avec les valeurs dans le form" form = request.form # Gestion des Dates date_debut: datetime = scu.is_iso_formated(form["date_debut"], True) date_fin: datetime = scu.is_iso_formated(form["date_fin"], True) if date_debut is None or date_fin is None or date_fin < date_debut: raise ScoValueError("Dates invalides", request.referrer) justi.date_debut = date_debut justi.date_fin = date_fin # Gestion de l'état etat = scu.EtatJustificatif.get(form["etat"]) if etat is not None: justi.etat = etat else: raise ScoValueError("État invalide", request.referrer) # Gestion de la raison justi.raison = form["raison"] # Gestion des fichiers files = request.files.getlist("justi_fich") if len(files) != 0: files = request.files.values() archive_name: str = justi.fichier # Utilisation de l'archiver de justificatifs archiver: JustificatifArchiver = JustificatifArchiver() for fich in files: archive_name, _ = archiver.save_justificatif( justi.etudiant, filename=fich.filename, data=fich.stream.read(), archive_name=archive_name, user_id=current_user.id, ) justi.fichier = archive_name justi.dejustifier_assiduites() db.session.add(justi) db.session.commit() justi.justifier_assiduites() scass.simple_invalidate_cache(justi.to_dict(True), justi.etudid) def _preparer_objet( obj_type: str, objet: Assiduite | Justificatif, sans_gros_objet: bool = False ) -> dict: "Préparation d'un objet pour simplifier l'affichage jinja" objet_prepare: dict = objet.to_dict() if obj_type == "assiduite": objet_prepare["etat"] = ( scu.EtatAssiduite(objet.etat).version_lisible().capitalize() ) objet_prepare["real_etat"] = scu.EtatAssiduite(objet.etat).name.lower() objet_prepare["description"] = ( "" if objet.description is None else objet.description ) objet_prepare["description"] = objet_prepare["description"].strip() # Gestion du module objet_prepare["module"] = objet.get_module(True) # Gestion justification if not objet.est_just: objet_prepare["justification"] = {"est_just": False} else: objet_prepare["justification"] = {"est_just": True, "justificatifs": []} if not sans_gros_objet: justificatifs: list[int] = get_assiduites_justif( objet.assiduite_id, False ) for justi_id in justificatifs: justi: Justificatif = Justificatif.query.get(justi_id) objet_prepare["justification"]["justificatifs"].append( _preparer_objet("justificatif", justi, sans_gros_objet=True) ) else: # objet == "justificatif" justif: Justificatif = objet objet_prepare["etat"] = ( scu.EtatJustificatif(justif.etat).version_lisible().capitalize() ) objet_prepare["real_etat"] = scu.EtatJustificatif(justif.etat).name.lower() objet_prepare["raison"] = "" if justif.raison is None else justif.raison objet_prepare["raison"] = objet_prepare["raison"].strip() objet_prepare["justification"] = {"assiduites": [], "fichiers": {}} if not sans_gros_objet: assiduites: list[int] = scass.justifies(justif) for assi_id in assiduites: assi: Assiduite = Assiduite.query.get(assi_id) objet_prepare["justification"]["assiduites"].append( _preparer_objet("assiduite", assi, sans_gros_objet=True) ) # fichiers justificatifs archivés: filenames, nb_files = justif.get_fichiers() objet_prepare["justification"]["fichiers"] = { "total": nb_files, "filenames": filenames, } objet_prepare["date_fin"] = objet.date_fin.strftime(scu.DATEATIME_FMT) objet_prepare["real_date_fin"] = objet.date_fin.isoformat() objet_prepare["date_debut"] = objet.date_debut.strftime(scu.DATEATIME_FMT) objet_prepare["real_date_debut"] = objet.date_debut.isoformat() objet_prepare["entry_date"] = objet.entry_date.strftime(scu.DATEATIME_FMT) objet_prepare["etud_nom"] = objet.etudiant.nomprenom if objet.user_id is not None: user: User = User.query.get(objet.user_id) objet_prepare["saisie_par"] = user.get_nomprenom() else: objet_prepare["saisie_par"] = "Inconnu" return objet_prepare @bp.route("/signal_assiduites_diff") @scodoc @permission_required(Permission.AbsChange) def signal_assiduites_diff(): """ Utilisé notamment par "Saisie différée" sur tableau de bord semetstre" Arguments de la requête: - group_ids : liste des groupes example : group_ids=1,2,3 - formsemestre_id : id du formsemestre example : formsemestre_id=1 - moduleimpl_id : id du moduleimpl example : moduleimpl_id=1 (Permet de pré-générer une plage. Si non renseigné, la plage sera vide) (Les trois valeurs suivantes doivent être renseignées ensemble) - date example : date=01/01/2021 - heure_debut example : heure_debut=08:00 - heure_fin example : heure_fin=10:00 Exemple de requête : signal_assiduites_diff?formsemestre_id=67&group_ids=400&moduleimpl_id=1229&date=15/04/2024&heure_debut=12:34&heure_fin=12:55 """ # Récupération des paramètres de la requête group_ids: list[int] = request.args.get("group_ids", None) formsemestre_id: int = request.args.get("formsemestre_id", -1) formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) etudiants: list[Identite] = [] # Vérification des groupes if group_ids is None: group_ids = [] else: group_ids = group_ids.split(",") map(str, group_ids) groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True ) if not groups_infos.members: return ( html_sco_header.sco_header(page_title="Assiduité: saisie différée") + "

Aucun étudiant !

" + html_sco_header.sco_footer() ) # Récupération des étudiants etudiants.extend( [Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members] ) etudiants = list(sorted(etudiants, key=lambda etud: etud.sort_key)) if groups_infos.tous_les_etuds_du_sem: gr_tit = "en" else: if len(groups_infos.group_ids) > 1: grp = "des groupes" else: grp = "du groupe" gr_tit = ( grp + ' ' + groups_infos.groups_titles + "" ) # Pré-remplissage des sélecteurs moduleimpl_id = request.args.get("moduleimpl_id", -1) try: moduleimpl_id = int(moduleimpl_id) except ValueError: moduleimpl_id = -1 # date fra (dd/mm/yyyy) date = request.args.get("date", "") # heures (hh:mm) heure_deb = request.args.get("heure_debut", "") heure_fin = request.args.get("heure_fin", "") # vérifications des sélecteurs date = date if re.match(r"^\d{2}\/\d{2}\/\d{4}$", date) else "" heure_deb = heure_deb if re.match(r"^[0-2]\d:[0-5]\d$", heure_deb) else "" heure_fin = heure_fin if re.match(r"^[0-2]\d:[0-5]\d$", heure_fin) else "" nouv_plage: list[str] = [date, heure_deb, heure_fin] return render_template( "assiduites/pages/signal_assiduites_diff.j2", etudiants=etudiants, moduleimpl_select=_module_selector( formsemestre=formsemestre, moduleimpl_id=moduleimpl_id ), gr=gr_tit, nonworkdays=_non_work_days(), sco=ScoData(formsemestre=formsemestre), forcer_module=sco_preferences.get_preference( "forcer_module", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ), non_present=sco_preferences.get_preference( "non_present", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ), nouv_plage=nouv_plage, ) @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.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, ) 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 _get_days_between_dates(deb: str, fin: str) -> str: """ _get_days_between_dates récupère tous les jours entre deux dates Args: deb (str): date de début fin (str): date de fin Returns: str: une chaine json représentant une liste des jours ['date_iso','date_iso2', ...] """ if deb is None or fin is None: return "null" try: if isinstance(deb, str) and isinstance(fin, str): date_deb: datetime.date = datetime.date.fromisoformat(deb) date_fin: datetime.date = datetime.date.fromisoformat(fin) else: date_deb, date_fin = deb.date(), fin.date() except ValueError: return "null" dates: list[str] = [] while date_deb <= date_fin: dates.append(f'"{date_deb.isoformat()}"') date_deb = date_deb + datetime.timedelta(days=1) return f"[{','.join(dates)}]" def _differee( etudiants: list[dict], moduleimpl_select: str, date: str = None, periode: dict[str, str] = None, formsemestre_id: int = None, ) -> str: """ _differee Génère un tableau de saisie différé Args: etudiants (list[dict]): la liste des étudiants (représentés par des dictionnaires) moduleimpl_select (str): l'html représentant le selecteur de module date (str, optional): la première date à afficher. Defaults to None. periode (dict[str, str], optional):La période par défaut de la première colonne. formsemestre_id (int, optional): l'id du semestre pour le selecteur de module. Returns: str: le widget (html/css/js) """ if date is None: date = datetime.date.today().isoformat() forcer_module = sco_preferences.get_preference( "forcer_module", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ) assi_etat_defaut = sco_preferences.get_preference( "assi_etat_defaut", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ) periode_defaut = sco_preferences.get_preference( "periode_defaut", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ) return render_template( "assiduites/widgets/differee.j2", etudiants=etudiants, assi_etat_defaut=assi_etat_defaut, periode_defaut=periode_defaut, forcer_module=forcer_module, moduleimpl_select=moduleimpl_select, date=date, periode=periode, ) def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> str: """ _module_selector Génère un HTMLSelectElement à partir des moduleimpl du formsemestre Args: formsemestre (FormSemestre): Le formsemestre d'où les moduleimpls seront pris. Returns: str: La représentation str d'un HTMLSelectElement """ # récupération des ues du semestre ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) modimpls_list: list[dict] = ntc.get_modimpls_dict() # prévoie la sélection par défaut d'un moduleimpl s'il a été passé en paramètre selected = "" if moduleimpl_id is not None else "selected" modules: list[dict[str, str | int]] = [] # Récupération de l'id et d'un nom lisible pour chaque moduleimpl for modimpl in modimpls_list: modname: str = ( (modimpl["module"]["code"] or "") + " " + (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "") ) modules.append({"moduleimpl_id": modimpl["moduleimpl_id"], "name": modname}) return render_template( "assiduites/widgets/moduleimpl_selector.j2", formsemestre_id=formsemestre.id, modules=modules, moduleimpl_id=moduleimpl_id, selected=selected, ) def _module_selector_multiple( etud: Identite, moduleimpl_id: int = None, only_form: FormSemestre = None ) -> str: """menu HTML