# -*- 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 re from typing import Any from flask import g, request, render_template, flash from flask import abort, url_for, redirect, Response from flask_login import current_user 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, ) from app.scodoc.codes_cursus import UE_STANDARD from app.auth.models import User from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified 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 from flask_sqlalchemy.query import Query CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS # --- UTILS --- class HTMLElement: """Représentation d'un HTMLElement version Python""" def __init__(self, tag: str, *attr, **kattr) -> None: self.tag: str = tag self.children: list["HTMLElement"] = [] self.self_close: bool = kattr.get("self_close", False) self.text_content: str = kattr.get("text_content", "") self.key_attributes: dict[str, Any] = kattr self.attributes: list[str] = list(attr) def add(self, *child: "HTMLElement") -> None: """add child element to self""" for kid in child: self.children.append(kid) def remove(self, child: "HTMLElement") -> None: """Remove child element from self""" if child in self.children: self.children.remove(child) def __str__(self) -> str: attr: list[str] = self.attributes for att, val in self.key_attributes.items(): if att in ("self_close", "text_content"): continue if att != "cls": attr.append(f'{att}="{val}"') else: attr.append(f'class="{val}"') if not self.self_close: head: str = f"<{self.tag} {' '.join(attr)}>{self.text_content}" body: str = "\n".join(map(str, self.children)) foot: str = f"" return head + body + foot return f"<{self.tag} {' '.join(attr)}/>" def __add__(self, other: str): return str(self) + other def __radd__(self, other: str): return other + str(self) class HTMLStringElement(HTMLElement): """Utilisation d'une chaine de caracètres pour représenter un element""" def __init__(self, text: str) -> None: self.text: str = text HTMLElement.__init__(self, "textnode") def __str__(self) -> str: return self.text class HTMLBuilder: def __init__(self, *content: HTMLElement | str) -> None: self.content: list[HTMLElement | str] = list(content) def add(self, *element: HTMLElement | str): self.content.extend(element) def remove(self, element: HTMLElement | str): if element in self.content: self.content.remove(element) def __str__(self) -> str: return "\n".join(map(str, self.content)) def build(self) -> str: return self.__str__() # -------------------------------------------------------------------- # # 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) annee = formsemestre.annee_scolaire() 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] # 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), ) @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 = { "": [("", "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) choices[formsemestre.titre_annee()] = [ (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 ] form.modimpl.choices = choices 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) # 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}", ) 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, ) -> 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, "%d/%m/%Y") 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, "%d/%m/%Y") if form.date_fin.data else None ) except ValueError: date_fin = None form.set_error("date fin invalide", form.date_fin) if date_fin: # ignore les heures si plusieurs jours heure_debut = datetime.time.fromisoformat(debut_jour) # 0h heure_fin = datetime.time.fromisoformat(fin_jour) # minuit 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épot (si vide, la date actuelle) try: dt_entry_date = ( datetime.datetime.strptime(form.entry_date.data, "%d/%m/%Y") 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) # Ajoute time zone serveur dt_debut_tz_server = scu.TIME_ZONE.localize(dt_debut) dt_fin_tz_server = scu.TIME_ZONE.localize(dt_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, ) -> 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 compute_assiduites_justified(etud.id, [justi]) # Invalider cache scass.simple_invalidate_cache(ass.to_dict(), etud.id) return True except ScoValueError as exc: form.set_error(f"Erreur: {exc.args[0]}") 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 header: str = html_sco_header.sco_header( page_title=f"Assiduité de {etud.nomprenom}", init_qtip=True, javascripts=[ "js/assiduites.js", "js/date_utils.js", ], cssstyles=CSSSTYLES + [ "css/assiduites.css", ], ) 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] # Peuplement du template jinja return HTMLBuilder( header, render_template( "assiduites/pages/liste_assiduites.j2", sco=ScoData(etud), assi_id=assiduite_id, tableau=tableau[1], ), ).build() @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") # Préparation de la page (header) header: str = html_sco_header.sco_header( page_title=f"Bilan de l'assiduité de {etud.nomprenom}", init_qtip=True, javascripts=[ "js/assiduites.js", "js/date_utils.js", ], cssstyles=CSSSTYLES + [ "css/assiduites.css", ], ) # Gestion des dates du bilan (par défaut l'année scolaire) date_debut = scu.date_debut_annee_scolaire().strftime("%d/%m/%Y") date_fin: str = scu.date_fin_annee_scolaire().strftime("%d/%m/%Y") # 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 return HTMLBuilder( header, render_template( "assiduites/pages/bilan_etud.j2", sco=ScoData(etud), date_debut=date_debut, date_fin=date_fin, assi_metric=assi_metric, assi_seuil=_get_seuil(), assi_limit_annee=sco_preferences.get_preference( "assi_limit_annee", dept_id=g.scodoc_dept_id, ), tableau=table[1], ), ).build() @bp.route("/edit_justificatif_etud/", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def edit_justificatif_etud(justif_id: int): """ Edition d'un justificatif 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("%d/%m/%Y") form.date_fin.data = justif.date_fin.strftime("%d/%m/%Y") 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("%H:%M") form.heure_fin.data = justif.date_fin.strftime("%H:%M") form.entry_date.data = ( justif.entry_date.strftime("%d/%m/%Y") if justif.entry_date else "" ) form.etat.data = str(justif.etat) redirect_url = url_for( "assiduites.liste_assiduites_etud", scodoc_dept=g.scodoc_dept, etudid=justif.etudiant.id, ) if form.validate_on_submit(): 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", assi_limit_annee=sco_preferences.get_preference( "assi_limit_annee", dept_id=g.scodoc_dept_id, ), etud=justif.etudiant, filenames=filenames, form=form, justif=justif, nb_files=nb_files, page_title="Modification justificatif", redirect_url=redirect_url, sco=ScoData(justif.etudiant), scu=scu, ) @bp.route( "/ajout_justificatif_etud", methods=["GET", "POST"] ) # was AjoutJustificatifEtud @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", assi_limit_annee=sco_preferences.get_preference( "assi_limit_annee", dept_id=g.scodoc_dept_id, ), etud=etud, form=form, page_title="Justificatifs", 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) 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 if justif.edit_from_form(form): message = "Justificatif modifié" else: message = "Pas de modification" 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() compute_assiduites_justified(etud.id, [justif]) 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: annees.extend( (ins.formsemestre.date_debut.year, ins.formsemestre.date_fin.year) ) annees = sorted(annees, reverse=True) # Transformation en une liste "json" # (sera utilisé pour générer le selecteur d'année) annees_str: str = "[" for ann in annees: annees_str += f"{ann}," annees_str += "]" 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: 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, "%d/%m/%Y" ).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("%d/%m/%Y"), fin=formsemestre.date_fin.strftime("%d/%m/%Y"), ) @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") # Vérification du forçage du module require_module = sco_preferences.get_preference("forcer_module", formsemestre_id) # 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 + "" ) # --- Génération de l'HTML --- header: str = html_sco_header.sco_header( page_title="Saisie journalière des assiduités", init_qtip=True, javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ # Voir fonctionnement JS "js/etud_info.js", "js/groups_view.js", "js/assiduites.js", "js/date_utils.js", ], cssstyles=CSSSTYLES + [ "css/assiduites.css", "css/minitimeline.css", ], ) # Récupération du semestre en dictionnaire sem = formsemestre.to_dict() # Peuplement du template jinja return HTMLBuilder( header, _mini_timeline(), render_template( "assiduites/pages/signal_assiduites_group.j2", gr_tit=gr_tit, sem=sem["titre_num"], date=_dateiso_to_datefr(date), formsemestre_id=formsemestre_id, grp=sco_groups_view.menu_groups_choice(groups_infos), moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), nonworkdays=_non_work_days(), formsemestre_date_debut=str(formsemestre.date_debut), formsemestre_date_fin=str(formsemestre.date_fin), forcer_module=sco_preferences.get_preference( "forcer_module", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ), defdem=_get_etuds_dem_def(formsemestre), readonly="false", ), html_sco_header.sco_footer(), ).build() @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") # Vérfication du forçage du module require_module = sco_preferences.get_preference("forcer_module", formsemestre_id) # 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 de l'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 + "" ) header: str = html_sco_header.sco_header( page_title="Saisie journalière de l'assiduité", init_qtip=True, javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ # Voir fonctionnement JS "js/etud_info.js", "js/groups_view.js", "js/assiduites.js", "js/date_utils.js", ], cssstyles=CSSSTYLES + [ "css/assiduites.css", "css/minitimeline.css", ], ) # Récupération du semestre en dictionnaire sem = formsemestre.to_dict() return HTMLBuilder( header, _mini_timeline(), render_template( "assiduites/pages/signal_assiduites_group.j2", gr_tit=gr_tit, sem=sem["titre_num"], date=_dateiso_to_datefr(date), formsemestre_id=formsemestre_id, grp=sco_groups_view.menu_groups_choice(groups_infos), moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), timeline=_timeline(), nonworkdays=_non_work_days(), formsemestre_date_debut=str(formsemestre.date_debut), formsemestre_date_fin=str(formsemestre.date_fin), forcer_module=sco_preferences.get_preference( "forcer_module", formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id, ), defdem=_get_etuds_dem_def(formsemestre), readonly="true", ), html_sco_header.sco_footer(), ).build() 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 and 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""" # Récupération des paramètres de la requête dates = { "debut": request.args.get("date_debut"), "fin": request.args.get("date_fin"), } 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 ) # 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 = "", ) -> 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") # 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, ) 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("/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, ) compute_assiduites_justified(objet.etudiant.id, [justificatif_correspondant]) 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", sco=ScoData(etud=objet.etudiant), # type utilisé dans les actions modifier / détails (modifier.j2, details.j2) type="Justificatif" if obj_type == "justificatif" else "Assiduité", action=action, etud=objet.etudiant, objet=_preparer_objet(obj_type, objet), objet_name=objet_name, obj_id=obj_id, moduleimpl=module, ) # ----- 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 db.session.add(justi) db.session.commit() scass.compute_assiduites_justified(justi.etudid, reset=True) 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("%d/%m/%y à %H:%M") objet_prepare["real_date_fin"] = objet.date_fin.isoformat() objet_prepare["date_debut"] = objet.date_debut.strftime("%d/%m/%y à %H:%M") objet_prepare["real_date_debut"] = objet.date_debut.isoformat() objet_prepare["entry_date"] = objet.entry_date.strftime("%d/%m/%y à %H:%M") 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(): """TODO documenter""" # 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) date: str = request.args.get("jour", datetime.date.today().isoformat()) date_deb: str = request.args.get("date_deb") date_fin: str = request.args.get("date_fin") semaine: str = request.args.get("semaine") # Dans le cas où on donne une semaine plutot qu'un jour if semaine is not None: # On génère la semaine iso à partir de l'anne scolaire. semaine = ( f"{scu.annee_scolaire()}-W{semaine}" if "W" not in semaine else semaine ) # On met à jour les dates avec le date de debut et fin de semaine date_deb: datetime.date = datetime.datetime.strptime( semaine + "-1", "%Y-W%W-%w" ) date_fin: datetime.date = date_deb + datetime.timedelta(days=6) etudiants: list[Identite] = [] # --- Vérification de la date --- real_date = scu.is_iso_formated(date, True).date() if real_date < formsemestre.date_debut: date = formsemestre.date_debut.isoformat() elif real_date > formsemestre.date_fin: date = formsemestre.date_fin.isoformat() # 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)) # Génération de l'HTML header: str = html_sco_header.sco_header( page_title="Assiduité: saisie différée", init_qtip=True, cssstyles=[ "css/assiduites.css", ], javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ "js/assiduites.js", "js/date_utils.js", "js/etud_info.js", ], ) 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 + "" ) return HTMLBuilder( header, render_template( "assiduites/pages/signal_assiduites_diff.j2", diff=_differee( etudiants=etudiants, moduleimpl_select=_module_selector( formsemestre, request.args.get("moduleimpl_id", None) ), date=date, periode={ "deb": formsemestre.date_debut.isoformat(), "fin": formsemestre.date_fin.isoformat(), }, ), gr=gr_tit, sem=formsemestre.titre_num(), defdem=_get_etuds_dem_def(formsemestre), timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"), timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"), timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"), defaultDates=_get_days_between_dates(date_deb, date_fin), nonworkdays=_non_work_days(), ), html_sco_header.sco_footer(), ).build() @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é try: assiduite_unique: Assiduite = 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 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, ) ) 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("%H:%M")] 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. Defaults to None. formsemestre_id (int, optional): l'id du semestre pour le selecteur de module. Defaults to None. 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) ues = ntc.get_ues_stat_dict() modimpls_list: list[dict] = [] for ue in ues: # Ajout des moduleimpl de chaque ue dans la liste des moduleimpls modimpls_list += ntc.get_modimpls_dict(ue_id=ue["ue_id"]) # 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", selected=selected, modules=modules, moduleimpl_id=moduleimpl_id, ) def _module_selector_multiple( etud: Identite, moduleimpl_id: int = None, only_form: FormSemestre = None ) -> str: modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) choices = {} for formsemestre_id in modimpls_by_formsemestre: formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) if only_form is not None and formsemestre != only_form: continue # indique le nom du semestre dans le menu (optgroup) choices[formsemestre.titre_annee()] = [ { "moduleimpl_id": m.id, "name": f"{m.module.code} {m.module.abbrev or m.module.titre or ''}", } for m in modimpls_by_formsemestre[formsemestre_id] if m.module.ue.type == UE_STANDARD ] return render_template( "assiduites/widgets/moduleimpl_selector_multiple.j2", choices=choices, moduleimpl_id=moduleimpl_id, ) def _dynamic_module_selector() -> str: """ _dynamic_module_selector retourne l'html/css/javascript du selecteur de module dynamique Returns: str: l'html/css/javascript du selecteur de module dynamique """ return render_template( "assiduites/widgets/moduleimpl_dynamic_selector.j2", ) def _timeline(formsemestre_id: int = None, heures=None) -> str: """ _timeline retourne l'html de la timeline Args: formsemestre_id (int, optional): un formsemestre. Defaults to None. Le formsemestre sert à obtenir la période par défaut de la timeline sinon ce sera de 2 heure dès le début de la timeline Returns: str: l'html en chaîne de caractères """ return render_template( "assiduites/widgets/timeline.j2", t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"), t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"), tick_time=ScoDocSiteConfig.get("assi_tick_time", 15), periode_defaut=sco_preferences.get_preference( "periode_defaut", formsemestre_id ), heures=heures, ) def _mini_timeline() -> str: """ _mini_timeline Retourne l'html lié au mini timeline d'assiduités Returns: str: l'html en chaîne de caractères """ return render_template( "assiduites/widgets/minitimeline.j2", t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"), t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"), ) def _non_work_days() -> str: """Abbréviation des jours non travaillés: "'sam','dim'". donnés par les préférences du département """ non_travail = sco_preferences.get_preference("non_travail") non_travail = non_travail.replace(" ", "").split(",") return ",".join([f"'{i.lower()}'" for i in non_travail]) def _get_seuil() -> int: """Seuil d'alerte des absences (en unité de la métrique), tel que fixé dans les préférences du département.""" return sco_preferences.get_preference("assi_seuil", dept_id=g.scodoc_dept_id) def _get_etuds_dem_def(formsemestre) -> str: """Une chaine json donnant les étudiants démissionnaires ou défaillants du formsemestre, sous la forme '{"516" : "D", ... }' """ return ( "{" + ", ".join( [ f'"{ins.etudid}" : "{ins.etat}"' for ins in formsemestre.inscriptions if ins.etat != scu.INSCRIT ] ) + "}" ) # --- Gestion du calendrier --- def generate_calendar( etudiant: Identite, annee: int = None, ) -> dict[str, list["Jour"]]: # Si pas d'année alors on prend l'année scolaire en cours if annee is None: annee = scu.annee_scolaire() # On prend du 01/09 au 31/08 date_debut: datetime.datetime = datetime.datetime(annee, 9, 1, 0, 0) date_fin: datetime.datetime = datetime.datetime(annee + 1, 8, 31, 23, 59) # Filtrage des assiduités et des justificatifs en fonction de la periode / année etud_assiduites: Query = scass.filter_by_date( etudiant.assiduites, Assiduite, date_deb=date_debut, date_fin=date_fin, ) etud_justificatifs: Query = scass.filter_by_date( etudiant.justificatifs, Justificatif, date_deb=date_debut, date_fin=date_fin, ) # Récupération des jours de l'année et de leurs assiduités/justificatifs annee_par_mois: dict[str, list[Jour]] = _organize_by_month( _get_dates_between( deb=date_debut.date(), fin=date_fin.date(), ), etud_assiduites, etud_justificatifs, ) return annee_par_mois class Jour: """Jour Jour du calendrier get_nom : retourne le numéro et le nom du Jour (ex: M19 / Mer 19) """ def __init__(self, date: datetime.date, assiduites: Query, justificatifs: Query): self.date = date self.assiduites = assiduites self.justificatifs = justificatifs def get_nom(self, mode_demi: bool = True) -> str: str_jour: str = scu.DAY_NAMES[self.date.weekday()].capitalize() return f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour[:3]+' '}{self.date.day}" def get_date(self) -> str: return self.date.strftime("%d/%m/%Y") def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str: etat = "" est_just = "" if self.is_non_work(): return "color nonwork" etat = self._get_color_assiduites_cascade( self._get_etats_from_assiduites(self.assiduites), show_pres=show_pres, show_reta=show_reta, ) est_just = self._get_color_justificatifs_cascade( self._get_etats_from_justificatifs(self.justificatifs), ) return f"color {etat} {est_just}" def get_demi_class( self, matin: bool, show_pres: bool = False, show_reta: bool = False ) -> str: # Transformation d'une heure "HH:MM" en time(h,m) STR_TIME = lambda x: datetime.time(*list(map(int, x.split(":")))) heure_midi = STR_TIME(ScoDocSiteConfig.get("assi_lunch_time", "13:00")) if matin: heure_matin = STR_TIME(ScoDocSiteConfig.get("assi_morning_time", "08:00")) matin = ( # date debut scu.localize_datetime( datetime.datetime.combine(self.date, heure_matin) ), # date fin scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)), ) assiduites_matin = [ assi for assi in self.assiduites if scu.is_period_overlapping((assi.date_debut, assi.date_fin), matin) ] justificatifs_matin = [ justi for justi in self.justificatifs if scu.is_period_overlapping((justi.date_debut, justi.date_fin), matin) ] etat = self._get_color_assiduites_cascade( self._get_etats_from_assiduites(assiduites_matin), show_pres=show_pres, show_reta=show_reta, ) est_just = self._get_color_justificatifs_cascade( self._get_etats_from_justificatifs(justificatifs_matin), ) return f"color {etat} {est_just}" heure_soir = STR_TIME(ScoDocSiteConfig.get("assi_afternoon_time", "17:00")) # séparation en demi journées aprem = ( # date debut scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)), # date fin scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)), ) assiduites_aprem = [ assi for assi in self.assiduites if scu.is_period_overlapping((assi.date_debut, assi.date_fin), aprem) ] justificatifs_aprem = [ justi for justi in self.justificatifs if scu.is_period_overlapping((justi.date_debut, justi.date_fin), aprem) ] etat = self._get_color_assiduites_cascade( self._get_etats_from_assiduites(assiduites_aprem), show_pres=show_pres, show_reta=show_reta, ) est_just = self._get_color_justificatifs_cascade( self._get_etats_from_justificatifs(justificatifs_aprem), ) return f"color {etat} {est_just}" def has_assiduites(self) -> bool: return self.assiduites.count() > 0 def generate_minitimeline(self) -> str: # Récupérer le référenciel de la timeline STR_TIME = lambda x: _time_to_timedelta( datetime.time(*list(map(int, x.split(":")))) ) heure_matin: datetime.timedelta = STR_TIME( ScoDocSiteConfig.get("assi_morning_time", "08:00") ) heure_midi: datetime.timedelta = STR_TIME( ScoDocSiteConfig.get("assi_lun_time", "13:00") ) heure_soir: datetime.timedelta = STR_TIME( ScoDocSiteConfig.get("assi_afternoon_time", "17:00") ) # longueur_timeline = heure_soir - heure_matin longueur_timeline: datetime.timedelta = heure_soir - heure_matin # chaque block d'assiduité est défini par: # longueur = ( (fin-deb) / longueur_timeline ) * 100 # emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100 assiduite_blocks: list[dict[str, float | str]] = [] for assi in self.assiduites: deb: datetime.timedelta = _time_to_timedelta( assi.date_debut.time() if assi.date_debut.date() == self.date else heure_matin ) fin: datetime.timedelta = _time_to_timedelta( assi.date_fin.time() if assi.date_fin.date() == self.date else heure_soir ) longueur: float = ((fin - deb) / longueur_timeline) * 100 emplacement: float = ((deb - heure_matin) / longueur_timeline) * 100 etat: str = scu.EtatAssiduite(assi.etat).name.lower() est_just: str = "est_just" if assi.est_just else "" assiduite_blocks.append( { "longueur": longueur, "emplacement": emplacement, "etat": etat, "est_just": est_just, "bubble": _generate_assiduite_bubble(assi), "id": assi.assiduite_id, } ) return render_template( "assiduites/widgets/minitimeline_simple.j2", assi_blocks=assiduite_blocks, ) def is_non_work(self): return self.date.weekday() in scu.NonWorkDays.get_all_non_work_days( dept_id=g.scodoc_dept_id ) def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]: return list(set([scu.EtatAssiduite(assi.etat) for assi in assiduites])) def _get_etats_from_justificatifs( self, justificatifs: Query ) -> list[scu.EtatJustificatif]: return list(set([scu.EtatJustificatif(justi.etat) for justi in justificatifs])) def _get_color_assiduites_cascade( self, etats: list[scu.EtatAssiduite], show_pres: bool = False, show_reta: bool = False, ) -> str: if scu.EtatAssiduite.ABSENT in etats: return "absent" if scu.EtatAssiduite.RETARD in etats and show_reta: return "retard" if scu.EtatAssiduite.PRESENT in etats and show_pres: return "present" return "sans_etat" def _get_color_justificatifs_cascade( self, etats: list[scu.EtatJustificatif], ) -> str: if scu.EtatJustificatif.VALIDE in etats: return "est_just" if scu.EtatJustificatif.ATTENTE in etats: return "attente" if scu.EtatJustificatif.MODIFIE in etats: return "modifie" if scu.EtatJustificatif.NON_VALIDE in etats: return "invalide" return "" def _get_dates_between(deb: datetime.date, fin: datetime.date) -> list[datetime.date]: resultat = [] date_actuelle = deb while date_actuelle <= fin: resultat.append(date_actuelle) date_actuelle += datetime.timedelta(days=1) return resultat def _organize_by_month(days, assiduites, justificatifs) -> dict[str, list[Jour]]: """ Organiser les dates par mois. """ organized = {} for date in days: # Récupérer le mois en français month = scu.MONTH_NAMES_ABBREV[date.month - 1] # Ajouter le jour à la liste correspondante au mois if month not in organized: organized[month] = [] date_assiduites: Query = scass.filter_by_date( assiduites, Assiduite, date_deb=datetime.datetime.combine(date, datetime.time(0, 0)), date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)), ) date_justificatifs: Query = scass.filter_by_date( justificatifs, Justificatif, date_deb=datetime.datetime.combine(date, datetime.time(0, 0)), date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)), ) # On génère un `Jour` composé d'une date, et des assiduités/justificatifs du jour jour: Jour = Jour(date, date_assiduites, date_justificatifs) organized[month].append(jour) return organized def _time_to_timedelta(t: datetime.time) -> datetime.timedelta: if isinstance(t, datetime.timedelta): return t return datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second) def _generate_assiduite_bubble(assiduite: Assiduite) -> str: # Récupérer informations modules impl moduleimpl_infos: str = assiduite.get_module(traduire=True) # Récupérer informations saisie saisie: str = assiduite.get_saisie() motif: str = assiduite.description if assiduite.description else "" return render_template( "assiduites/widgets/assiduite_bubble.j2", moduleimpl=moduleimpl_infos, etat=scu.EtatAssiduite(assiduite.etat).name.lower(), date_debut=assiduite.date_debut.strftime("%d/%m/%Y %H:%M"), date_fin=assiduite.date_fin.strftime("%d/%m/%Y %H:%M"), saisie=saisie, motif=motif, )