# -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2023 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 flask import g, request, render_template, flash from flask import abort, url_for, redirect from flask_login import current_user from app import db from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.decorators import ( scodoc, permission_required, ) from app.models import ( FormSemestre, Identite, ScoDocSiteConfig, Assiduite, Justificatif, Departement, Evaluation, ) 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 safehtml 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 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("/BilanDept") @scodoc @permission_required(Permission.AbsChange) def bilan_dept(): """Gestionnaire assiduités, page principale""" # Préparation de la page H = [ html_sco_header.sco_header( page_title="Saisie de l'assiduité", javascripts=[ "js/assiduites.js", "js/date_utils.js", ], cssstyles=[ "css/assiduites.css", ], ), """

Traitement de l'assiduité

Pour saisir l'assiduité ou consulter les états, il est recommandé de passer par le semestre concerné (saisie par jour ou saisie différée).

""", ] H.append( """

Pour signaler, annuler ou justifier l'assiduité d'un seul étudiant, choisissez d'abord la personne concernée :

""" ) # Ajout de la barre de recherche d'étudiant (redirection vers bilan etud) H.append(sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud")) # Gestion des billets d'absences if current_user.has_permission( Permission.AbsChange ) and sco_preferences.get_preference("handle_billets_abs"): H.append( f"""

Billets d'absence

""" ) # Récupération des années d'étude du département # (afin de sélectionner une année) dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first() annees: list[int] = sorted( [f.date_debut.year for f in dept.formsemestres], reverse=True, ) annee = scu.annee_scolaire() # Année courante, sera utilisée par défaut # Génération d'une liste "json" d'années annees_str: str = "[" for ann in annees: annees_str += f"{ann}," annees_str += "]" # Récupération d'un formsemestre # (pour n'afficher que les assiduites/justificatifs liés au formsemestre) formsemestre_id = request.args.get("formsemestre_id", "") if formsemestre_id: try: formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) annee = formsemestre.annee_scolaire() except AttributeError: formsemestre_id = "" # Peuplement du template jinja H.append( render_template( "assiduites/pages/bilan_dept.j2", dept_id=g.scodoc_dept_id, annee=annee, annees=annees_str, formsemestre_id=formsemestre_id, group_id=request.args.get("group_id", ""), ), ) H.append(html_sco_header.sco_footer()) return "\n".join(H) @bp.route("/SignaleAssiduiteEtud") @scodoc @permission_required(Permission.AbsChange) def signal_assiduites_etud(): """ signal_assiduites_etud Saisie de l'assiduité d'un étudiant 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") # Récupération de la date (par défaut la date du jour) date = request.args.get("date", datetime.date.today().isoformat()) heures: list[str] = [ request.args.get("heure_deb", ""), request.args.get("heure_fin", ""), ] # gestion évaluations (Appel à la page depuis les évaluations) saisie_eval: bool = request.args.get("saisie_eval") is not None date_deb: str = request.args.get("date_deb") date_fin: str = request.args.get("date_fin") moduleimpl_id: int = request.args.get("moduleimpl_id", "") evaluation_id: int = request.args.get("evaluation_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, ) ) # Préparation de la page (Header) header: str = html_sco_header.sco_header( page_title="Saisie assiduité", init_qtip=True, javascripts=[ "js/assiduites.js", "js/date_utils.js", "js/etud_info.js", ], cssstyles=CSSSTYLES + [ "css/assiduites.css", ], ) # Gestion des horaires (journée, matin, soir) morning = ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00") lunch = ScoDocSiteConfig.assi_get_rounded_time("assi_lunch_time", "13:00:00") afternoon = ScoDocSiteConfig.assi_get_rounded_time( "assi_afternoon_time", "18:00:00" ) # Gestion du selecteur de moduleimpl (pour le tableau différé) select = f""" """ # Génération de la page return HTMLBuilder( header, _mini_timeline(), render_template( "assiduites/pages/ajout_assiduites.j2", sco=ScoData(etud), assi_limit_annee=sco_preferences.get_preference( "assi_limit_annee", dept_id=g.scodoc_dept_id, ), assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), saisie_eval=saisie_eval, date_deb=date_deb, date_fin=date_fin, redirect_url=redirect_url, moduleimpl_id=moduleimpl_id, ), # render_template( # "assiduites/pages/signal_assiduites_etud.j2", # sco=ScoData(etud), # date=_dateiso_to_datefr(date), # morning=morning, # lunch=lunch, # timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), # afternoon=afternoon, # nonworkdays=_non_work_days(), # forcer_module=sco_preferences.get_preference( # "forcer_module", dept_id=g.scodoc_dept_id # ), # diff=_differee( # etudiants=[sco_etud.get_etud_info(etudid=etud.etudid, filled=True)[0]], # moduleimpl_select=select, # ), # saisie_eval=saisie_eval, # date_deb=date_deb, # date_fin=date_fin, # redirect_url=redirect_url, # moduleimpl_id=moduleimpl_id, # ), ).build() @bp.route("/ListeAssiduitesEtud") @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) 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", ], ) # Peuplement du template jinja return HTMLBuilder( header, render_template( "assiduites/pages/liste_assiduites.j2", sco=ScoData(etud), date=datetime.date.today().isoformat(), assi_id=assiduite_id, assi_limit_annee=sco_preferences.get_preference( "assi_limit_annee", dept_id=g.scodoc_dept_id, ), ), ).build() @bp.route("/BilanEtud") @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: str = f"01/09/{scu.annee_scolaire()}" date_fin: str = f"30/06/{scu.annee_scolaire()+1}" # 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), ) # 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, ), ), ).build() @bp.route("/AjoutJustificatifEtud") @scodoc @permission_required(Permission.AbsChange) def ajout_justificatif_etud(): """ ajout_justificatif_etud : Affichage et création/modification des justificatifs de l'étudiant 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") # Préparation de la page (header) header: str = html_sco_header.sco_header( page_title="Justificatifs", init_qtip=True, javascripts=[ "js/assiduites.js", "js/date_utils.js", ], cssstyles=CSSSTYLES + [ "css/assiduites.css", ], ) # Peuplement du template jinja return HTMLBuilder( header, render_template( "assiduites/pages/ajout_justificatif.j2", sco=ScoData(etud), assi_limit_annee=sco_preferences.get_preference( "assi_limit_annee", dept_id=g.scodoc_dept_id, ), assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), ), ).build() @bp.route("/CalendrierAssiduitesEtud") @scodoc @permission_required(Permission.ScoView) def calendrier_etud(): """ calendrier_etud : Affichage d'un calendrier des assiduités 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") # Préparation de la page header: str = html_sco_header.sco_header( page_title="Calendrier de l'assiduité", init_qtip=True, javascripts=[ "js/assiduites.js", "js/date_utils.js", ], cssstyles=CSSSTYLES + [ "css/assiduites.css", ], ) # 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 += "]" # Peuplement du template jinja return HTMLBuilder( header, render_template( "assiduites/pages/calendrier.j2", sco=ScoData(etud), annee=scu.annee_scolaire(), nonworkdays=_non_work_days(), minitimeline=_mini_timeline(), annees=annees_str, ), ).build() @bp.route("/SignalAssiduiteGr") @scodoc @permission_required(Permission.AbsChange) def signal_assiduites_group(): """ signal_assiduites_group Saisie des assiduités des groupes pour le jour donnée Returns: str: l'html généré """ # Récupération des paramètres de l'url 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 # Vérification du formsemestre_id try: formsemestre_id = int(formsemestre_id) except (TypeError, ValueError): formsemestre_id = None # Gestion des groupes 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é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, indiquer une erreur # Formatage des dates pour le message d'erreur real_str = real_date.strftime("%d/%m/%Y") form_deb = formsemestre.date_debut.strftime("%d/%m/%Y") form_fin = formsemestre.date_fin.strftime("%d/%m/%Y") raise ScoValueError( f"Impossible de saisir l'assiduité pour le {real_str}" + f" : Jour en dehors du semestre ( {form_deb} → {form_fin}) " ) # --- 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", ], ) # 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("/VisuAssiduiteGr") @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: date = formsemestre.date_debut.isoformat() elif real_date > formsemestre.date_fin: date = formsemestre.date_fin.isoformat() # --- 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", ], ) # 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() @bp.route("/etat_abs_date") @scodoc @permission_required(Permission.ScoView) def etat_abs_date(): """date_debut, date_fin en ISO""" # Récupération des paramètre de la requête date_debut_str = request.args.get("date_debut") date_fin_str = request.args.get("date_fin") title = request.args.get("desc") group_ids: list[int] = request.args.get("group_ids", 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 # 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) # 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 ] # Récupération des assiduites des étudiants assiduites: Assiduite = Assiduite.query.filter( Assiduite.etudid.in_([e["etudid"] for e in etuds]) ) # Filtrage des assiduités en fonction des dates données assiduites = scass.filter_by_date( assiduites, Assiduite, date_debut, date_fin, False ) # Génération d'objet étudiant simplifié (nom+lien cal, etat_assiduite) etudiants: list[dict] = [] for etud in etuds: # On récupère l'état de la première assiduité sur la période assi = assiduites.filter_by(etudid=etud["etudid"]).first() etat = "" if assi is not None and assi.etat != 0: etat = scu.EtatAssiduite.inverse().get(assi.etat).name # On génère l'objet simplifié etudiant = { "nom": f"""{etud["nomprenom"]}""", "etat": etat, } etudiants.append(etudiant) # On tri les étudiants etudiants = list(sorted(etudiants, key=lambda x: x["nom"])) # Génération de l'HTML header: str = html_sco_header.sco_header( page_title=safehtml.html_to_safe_html(title), init_qtip=True, ) return HTMLBuilder( header, render_template( "assiduites/pages/etat_absence_date.j2", etudiants=etudiants, group_title=groups_infos.groups_titles, date_debut=date_debut, date_fin=date_fin, ), html_sco_header.sco_footer(), ).build() @bp.route("/VisualisationAssiduitesGroupe") @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.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}", ) @bp.route("/Test") @scodoc @permission_required(Permission.ScoView) def test(): """Visualisation de l'assiduité d'un groupe entre deux dates""" fmt = request.args.get("fmt", "html") etudid = request.args.get("etudid", 18114) from app.tables.liste_assiduites import ListeAssiJusti table: ListeAssiJusti = ListeAssiJusti(Identite.get_etud(etudid)) 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, ) return render_template( "assiduites/pages/test_assi.j2", sco=ScoData(), tableau=table.html(), title=f"Test tableau", ) @bp.route("/SignalAssiduiteDifferee") @scodoc @permission_required(Permission.AbsChange) def signal_assiduites_diff(): # 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) 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[dict] = [] titre = None # Vérification du formsemestre_id try: formsemestre_id = int(formsemestre_id) except (TypeError, ValueError): formsemestre_id = None formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) # --- 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) 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( [ sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] for m in groups_infos.members ] ) etudiants = list(sorted(etudiants, key=lambda x: x["nom"])) # 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", ], ) sem = formsemestre.to_dict() 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=sem["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("/SignalEvaluationAbs//") @scodoc @permission_required(Permission.ScoView) def signal_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érieur à 1 jour Alors 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 """ # Récupération de l'étudiant concerné etud: Identite = Identite.query.get_or_404(etudid) if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") # Récupération de l'évaluation concernée 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.signal_assiduites_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 see: # En cas d'erreur msg: str = see.args[0] if "Duplication" in msg: msg = "Une autre assiduité concerne déjà cette période. En cliquant sur continuer vous serez redirigé vers la page de saisie des assiduités de l'étudiant." dest: str = url_for( "assiduites.signal_assiduites_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 see db.session.add(assiduite_unique) db.session.commit() # on flash pour indiquer que l'absence a bien été créée 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, ) @bp.route("/test", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) def test(): """XXX fonction de test a retirer""" if request.method == "POST": print("test date_utils : ", request.form) return render_template("assiduites/pages/test.j2") # --- 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, ) return render_template( "assiduites/widgets/differee.j2", etudiants=etudiants, assi_etat_defaut=assi_etat_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" # Vérification que le moduleimpl_id passé en paramètre est bien un entier try: moduleimpl_id = int(moduleimpl_id) except (ValueError, TypeError): moduleimpl_id = None 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 _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 ] ) + "}" )