diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py new file mode 100644 index 0000000000..7492a4712a --- /dev/null +++ b/app/tables/liste_assiduites.py @@ -0,0 +1,354 @@ +from app.tables import table_builder as tb +from app.models import Identite, Assiduite, Justificatif +from datetime import datetime +from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif +from flask_sqlalchemy.query import Query, Pagination +from sqlalchemy import union, literal, select, desc +from app import db +from flask import url_for +from app import log + + +class ListeAssiJusti(tb.Table): + """ + Table listant les Assiduites et Justificatifs d'une collection d'étudiants + L'affichage par défaut se fait par ordre de date de fin décroissante. + """ + + NB_PAR_PAGE: int = 50 + + def __init__( + self, + *etudiants: tuple[Identite], + filtre: "Filtre" = None, + page: int = 1, + **kwargs, + ) -> None: + """ + __init__ Instancie un nouveau table de liste d'assiduités/justificaitifs + + Args: + filtre (Filtre, optional): Filtrage des objets à afficher. Defaults to None. + page (int, optional): numéro de page de la pagination. Defaults to 1. + """ + self.etudiants = etudiants + # Gestion du filtre, par défaut un filtre vide + self.filtre = filtre if filtre is not None else Filtre() + # Gestion de la pagination (par défaut page 1) + self.page = page + + # les lignes du tableau + self.rows: list["RowAssiJusti"] = [] + + # Instanciation de la classe parent + super().__init__( + row_class=RowAssiJusti, + classes=["gt_table", "gt_left"], + **kwargs, + with_foot_titles=False, + ) + + self.ajouter_lignes() + + def ajouter_lignes(self): + # Générer les query assiduités et justificatifs + + assiduites_query_etudiants: Query = None + justificatifs_query_etudiants: Query = None + + # Récupération du filtrage des objets -> 0 : tout, 1 : Assi, 2: Justi + type_obj = self.filtre.type_obj() + + if type_obj in [0, 1]: + assiduites_query_etudiants = Assiduite.query.filter( + Assiduite.etudid.in_([e.etudid for e in self.etudiants]) + ) + if type_obj in [0, 2]: + justificatifs_query_etudiants = Justificatif.query.filter( + Justificatif.etudid.in_([e.etudid for e in self.etudiants]) + ) + + # Combinaison des requêtes + + query_finale: Query = self.joindre( + query_assiduite=assiduites_query_etudiants, + query_justificatif=justificatifs_query_etudiants, + ) + + # Paginer la requête pour ne pas envoyer trop d'informations au client + pagination: Pagination = self.paginer(query_finale) + + # Générer les lignes de la page + for ligne in pagination.items: + row: RowAssiJusti = self.row_class(self, ligne._asdict()) + row.ajouter_colonnes() + self.add_row(row) + + def paginer(self, query: Query) -> Pagination: + """ + Applique la pagination à une requête SQLAlchemy en fonction des paramètres de la classe. + + Cette méthode prend une requête SQLAlchemy et applique la pagination en utilisant les attributs `page` et + `NB_PAR_PAGE` de la classe `ListeAssiJusti`. + + Args: + query (Query): La requête SQLAlchemy à paginer. Il s'agit d'une requête qui a déjà été construite et + qui est prête à être exécutée. + + Returns: + Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée. + + Note: + Cette méthode ne modifie pas la requête originale; elle renvoie plutôt un nouvel objet qui contient les + résultats paginés. + """ + return query.paginate( + page=self.page, per_page=ListeAssiJusti.NB_PAR_PAGE, error_out=False + ) + + def joindre(self, query_assiduite: Query = None, query_justificatif: Query = None): + """ + Combine les requêtes d'assiduités et de justificatifs en une seule requête. + + Cette fonction prend en entrée deux requêtes optionnelles, une pour les assiduités et une pour les justificatifs, + et renvoie une requête combinée qui sélectionne un ensemble spécifique de colonnes pour chaque type d'objet. + + Les colonnes sélectionnées sont: + - obj_id: l'identifiant de l'objet (assiduite_id pour les assiduités, justif_id pour les justificatifs) + - etudid: l'identifiant de l'étudiant + - entry_date: la date de saisie de l'objet + - date_debut: la date de début de l'objet + - date_fin: la date de fin de l'objet + - etat: l'état de l'objet + - type: le type de l'objet ("assiduite" pour les assiduités, "justificatif" pour les justificatifs) + + Args: + query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les assiduités. + Si None, aucune assiduité ne sera incluse dans la requête combinée. Defaults to None. + query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les justificatifs. + Si None, aucun justificatif ne sera inclus dans la requête combinée. Defaults to None. + + Returns: + sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour obtenir les résultats. + + Raises: + ValueError: Si aucune requête n'est fournie (les deux paramètres sont None). + """ + queries = [] + + # Définir les colonnes pour la requête d'assiduité + if query_assiduite: + query_assiduite = query_assiduite.with_entities( + Assiduite.assiduite_id.label("obj_id"), + Assiduite.etudid.label("etudid"), + Assiduite.entry_date.label("entry_date"), + Assiduite.date_debut.label("date_debut"), + Assiduite.date_fin.label("date_fin"), + Assiduite.etat.label("etat"), + literal("assiduite").label("type"), + ) + queries.append(query_assiduite) + + # Définir les colonnes pour la requête de justificatif + if query_justificatif: + query_justificatif = query_justificatif.with_entities( + Justificatif.justif_id.label("obj_id"), + Justificatif.etudid.label("etudid"), + Justificatif.entry_date.label("entry_date"), + Justificatif.date_debut.label("date_debut"), + Justificatif.date_fin.label("date_fin"), + Justificatif.etat.label("etat"), + literal("justificatif").label("type"), + ) + queries.append(query_justificatif) + + # S'assurer qu'au moins une requête est fournie + if not queries: + raise ValueError( + "Au moins une query (assiduité ou justificatif) doit être fournie" + ) + + # Combiner les requêtes avec une union + query_combinee = union(*queries).alias("combinee") + + query_combinee = db.session.query(query_combinee).order_by(desc("date_debut")) + + return query_combinee + + +class RowAssiJusti(tb.Row): + def __init__(self, table: ListeAssiJusti, ligne: dict): + self.ligne: dict = ligne + self.etud: Identite = Identite.get_etud(ligne["etudid"]) + + super().__init__( + table=table, + row_id=f'{ligne["etudid"]}_{ligne["type"]}_{ligne["obj_id"]}', + ) + + def ajouter_colonnes(self, lien_redirection: str = None): + etud = self.etud + self.table.group_titles.update( + { + "etud_codes": "Codes", + "identite_detail": "", + "identite_court": "", + } + ) + + # Ajout des informations de l'étudiant + + self.add_cell( + "nom_disp", + "Nom", + etud.nom_disp(), + "etudinfo", + attrs={"id": str(etud.id)}, + data={"order": etud.sort_key}, + target=lien_redirection, + target_attrs={"class": "discretelink"}, + ) + self.add_cell( + "prenom", + "Prénom", + etud.prenom_str, + "etudinfo", + attrs={"id": str(etud.id)}, + data={"order": etud.sort_key}, + target=lien_redirection, + target_attrs={"class": "discretelink"}, + ) + # Type d'objet + self.add_cell( + "type", + "Type", + self.ligne["type"].capitalize(), + ) + # Etat de l'objet + objEnum: EtatAssiduite | EtatJustificatif = ( + EtatAssiduite if self.ligne["type"] == "assiduite" else EtatJustificatif + ) + + self.add_cell( + "etat", + "État", + objEnum.inverse().get(self.ligne["etat"]).name.capitalize(), + ) + + # Date de début + self.add_cell( + "date_debut", + "Date de début", + self.ligne["date_debut"].strftime("%d/%m/%y à %H:%M"), + data={"order": self.ligne["date_debut"]}, + ) + # Date de fin + self.add_cell( + "date_fin", + "Date de fin", + self.ligne["date_fin"].strftime("%d/%m/%y à %H:%M"), + data={"order": self.ligne["date_fin"]}, + ) + # Date de saisie + self.add_cell( + "entry_date", + "Saisie le", + self.ligne["entry_date"].strftime("%d/%m/%y à %H:%M"), + data={"order": self.ligne["entry_date"]}, + ) + + +class Filtre: + """ + Classe représentant le filtrage qui sera appliqué aux objets + du Tableau `ListeAssiJusti` + """ + + def __init__( + self, + type_obj: int = 0, + entry_date: tuple[int, datetime] = None, + date_debut: tuple[int, datetime] = None, + date_fin: tuple[int, datetime] = None, + etats: list[EtatAssiduite | EtatJustificatif] = None, + ) -> None: + """ + __init__ Instancie un nouvel objet filtre. + + Args: + type_obj (int, optional): type d'objet (0:Tout, 1: Assi, 2:Justi). Defaults to 0. + entry_date (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. + date_debut (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. + date_fin (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. + etats (list[int | EtatJustificatif | EtatAssiduite], optional): liste d'états valides (int | EtatJustificatif | EtatAssiduite). Defaults to None. + """ + + self.filtres = {} + + if entry_date is not None: + self.filtres["entry_date"]: tuple[int, datetime] = entry_date + + if date_debut is not None: + self.filtres["date_debut"]: tuple[int, datetime] = date_debut + + if date_fin is not None: + self.filtres["date_fin"]: tuple[int, datetime] = date_fin + + if etats is not None: + self.filtres["etats"]: list[int | EtatJustificatif | EtatAssiduite] = etats + + def filtrage(self, query: Query, obj_class: db.Model) -> Query: + """ + filtrage Filtre la query passée en paramètre et retourne l'objet filtré + + Args: + query (Query): La query à filtrer + + Returns: + Query: La query filtrée + """ + + query_filtree: Query = query + + cle_filtre: str + for cle_filtre, val_filtre in self.filtres.items(): + if "date" in cle_filtre: + type_filtrage: int + date: datetime + + type_filtrage, date = val_filtre + + match (type_filtrage): + # On garde uniquement les dates supérieur au filtre + case 2: + query_filtree = query_filtree.filter( + getattr(obj_class, cle_filtre) > date + ) + # On garde uniquement les dates inférieur au filtre + case 1: + query_filtree = query_filtree.filter( + getattr(obj_class, cle_filtre) < date + ) + # Par défaut on garde uniquement les dates égales au filtre + case _: + query_filtree = query_filtree.filter( + getattr(obj_class, cle_filtre) == date + ) + + if cle_filtre == "etats": + etats: list[int | EtatJustificatif | EtatAssiduite] = val_filtre + # On garde uniquement les objets ayant un état compris dans le filtre + query_filtree = query_filtree.filter(obj_class.etat.in_(etats)) + + return query_filtree + + def type_obj(self) -> int: + """ + type_obj Renvoi le/les types d'objets à représenter + + (0:Tout, 1: Assi, 2:Justi) + + Returns: + int: le/les types d'objets à afficher + """ + return self.filtres.get("type_obj", 0) diff --git a/app/tables/visu_assiduites.py b/app/tables/visu_assiduites.py index 4ccbae8f4f..4a48728fef 100644 --- a/app/tables/visu_assiduites.py +++ b/app/tables/visu_assiduites.py @@ -17,7 +17,7 @@ from app.scodoc import sco_utils as scu class TableAssi(tb.Table): - """Table listant l'assiduité des étudiants + """Table listant les statistiques d'assiduité des étudiants L'id de la ligne est etuid, et le row stocke etud. """ diff --git a/app/templates/assiduites/pages/test_assi.j2 b/app/templates/assiduites/pages/test_assi.j2 new file mode 100644 index 0000000000..3291c18959 --- /dev/null +++ b/app/templates/assiduites/pages/test_assi.j2 @@ -0,0 +1,13 @@ +{% extends "sco_page.j2" %} + +{% block scripts %} +{{ super() }} + +{% endblock %} + +{% block app_content %} + + +{{tableau | safe}} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/assiduites/pages/visu_assi.j2 b/app/templates/assiduites/pages/visu_assi.j2 index f947928f33..10af779563 100644 --- a/app/templates/assiduites/pages/visu_assi.j2 +++ b/app/templates/assiduites/pages/visu_assi.j2 @@ -1,8 +1,8 @@ {% extends "sco_page.j2" %} {% block scripts %} - {{ super() }} - +{{ super() }} + {% endblock %} {% block app_content %} @@ -21,8 +21,8 @@ {{tableau | safe}} -
-Les comptes sont exprimés en {{ assi_metric | lower}}s. +
+ Les comptes sont exprimés en {{ assi_metric | lower}}s.
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 81e734a9ec..96f2b78027 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -1014,6 +1014,33 @@ def visu_assi_group(): ) +@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") + + from app.tables.liste_assiduites import ListeAssiJusti + + table: ListeAssiJusti = ListeAssiJusti(Identite.get_etud(18114)) + + 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)