From 023e3a4c0418a8dd0fcdcc7a58c492d175d8085c Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 11 Jan 2024 17:24:01 +0100 Subject: [PATCH] Assiduites : pagination + tri + options tableaux --- app/models/assiduites.py | 1 + app/scodoc/sco_assiduites.py | 3 + app/scodoc/sco_cache.py | 53 ++++++ app/static/js/assiduites.js | 48 ++++++ app/tables/liste_assiduites.py | 178 ++++++++++++++------ app/templates/assiduites/widgets/tableau.j2 | 125 ++++++++++++-- app/views/assiduites.py | 35 +++- 7 files changed, 380 insertions(+), 63 deletions(-) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 3d6a296cf..46584d9a4 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -618,6 +618,7 @@ def compute_assiduites_justified( Returns: list[int]: la liste des assiduités qui ont été justifiées. """ + # TODO à optimiser (car très long avec 40000 assiduités) # Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant if justificatifs is None: justificatifs: list[Justificatif] = Justificatif.query.filter_by( diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 5f8287e60..c252b34ea 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -688,6 +688,7 @@ def invalidate_assiduites_count(etudid: int, sem: dict): sco_cache.AbsSemEtudCache.delete(key) +# Non utilisé def invalidate_assiduites_count_sem(sem: dict): """Invalidate (clear) cached abs counts for all the students of this semestre""" inscriptions = ( @@ -756,3 +757,5 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None): etudid = etudid if etudid is not None else obj["etudid"] invalidate_assiduites_etud_date(etudid, date_debut) invalidate_assiduites_etud_date(etudid, date_fin) + + sco_cache.RequeteTableauAssiduiteCache.delete_with(f"tableau-etud-{etudid}") diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index e31b6a18d..8d0a5da84 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -396,3 +396,56 @@ class ValidationsSemestreCache(ScoDocCache): prefix = "VSC" timeout = 60 * 60 # ttl 1 heure (en phase de mise au point) + + +class SimpleIndexCache(ScoDocCache): + prefix = "INDEX" + + +class RequeteTableauAssiduiteCache(ScoDocCache): + """ + clé : ":::>::" + Valeur = liste de dicts + """ + + prefix = "TABASSI" + timeout = 60 * 60 # Une heure + + @classmethod + def set(cls, oid: str, value: object): + """Ajoute une entrée au cache. Ajoute la clé dans la liste des clés du cache""" + keys_index = cls.get_index() + + # On met à jour l'index + if oid not in keys_index: + keys_index.append(oid) + SimpleIndexCache.set(cls.prefix + "_index", keys_index) + + # On cache la valeur + return super().set(oid, value) + + @classmethod + def get_index(cls) -> list: + """récupère la liste des clés des entrées du cache""" + # on définie un index des clés pour faciliter l'invalidation + keys_index: list = SimpleIndexCache.get(cls.prefix + "_index") + if keys_index is None: + keys_index = [] + + return keys_index + + @classmethod + def delete_with(cls, start: str): + """Invalide toutes les entrées de cache commençant par """ + keys_index: list[str] = cls.get_index() + + key: str + filtered_keys_index: list = [key for key in keys_index if key.startswith(start)] + + for key in filtered_keys_index: + cls.delete(key) + + SimpleIndexCache.set( + cls.prefix + "_index", + [k for k in keys_index if k not in filtered_keys_index], + ) diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 9f7e69e7a..59aefad19 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1,3 +1,51 @@ +function loadAssi(count, deb) { + let c = 0; + let a = new Date(deb); + a.setHours(0, 0, 0, 0); + const etat = ["present", "absent", "retard"]; + const etudid = 17888; + const path = getUrl() + `/api/assiduite/${etudid}/create`; + const assiduites = []; + while (c < count) { + if (a.getDay() > 0 && a.getDay() < 6) { + c++; + const date = a.toISOString().split("T")[0]; + const assis = [ + { + date_debut: date + "T08:00", + date_fin: date + "T10:00", + etat: etat[Math.floor(Math.random() * 3)], + }, + { + date_debut: date + "T10:15", + date_fin: date + "T12:15", + etat: etat[Math.floor(Math.random() * 3)], + }, + { + date_debut: date + "T13:15", + date_fin: date + "T15:15", + etat: etat[Math.floor(Math.random() * 3)], + }, + { + date_debut: date + "T15:30", + date_fin: date + "T17:00", + etat: etat[Math.floor(Math.random() * 3)], + }, + ]; + + assiduites.push(...assis); + } + a = new Date(a.valueOf() + 24 * 3600 * 1000); + } + + async_post( + path, + assiduites, + () => {}, + () => {} + ); +} + // <=== CONSTANTS and GLOBALS ===> let url; diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index 17d04c27c..1a2613765 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -1,14 +1,33 @@ from datetime import datetime from flask import url_for -from flask_sqlalchemy.query import Pagination, Query -from sqlalchemy import desc, literal, union +from flask_sqlalchemy.query import Query +from sqlalchemy import desc, literal, union, asc from app import db, g from app.auth.models import User from app.models import Assiduite, Identite, Justificatif from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool from app.tables import table_builder as tb +from app.scodoc.sco_cache import RequeteTableauAssiduiteCache + + +class Pagination: + def __init__(self, collection: list, page: int = 1, per_page: int = -1): + self.total_pages = 1 + + if per_page != -1: + q, r = len(collection) // per_page, len(collection) % per_page + self.total_pages = q if r == 0 else q + 1 + current_page: int = min(self.total_pages, page) + self.collection = ( + collection + if per_page == -1 + else collection[per_page * (current_page - 1) : per_page * (current_page)] + ) + + def items(self) -> list: + return self.collection class ListeAssiJusti(tb.Table): @@ -18,13 +37,15 @@ class ListeAssiJusti(tb.Table): """ NB_PAR_PAGE: int = 25 - MAX_PAR_PAGE: int = 200 + MAX_PAR_PAGE: int = 1000 def __init__( self, table_data: "AssiJustifData", filtre: "AssiFiltre" = None, options: "AssiDisplayOptions" = None, + no_pagination: bool = False, + titre: str = "", **kwargs, ) -> None: """ @@ -41,11 +62,16 @@ class ListeAssiJusti(tb.Table): # Gestion des options, par défaut un objet Options vide self.options = options if options is not None else AssiDisplayOptions() + self.no_pagination: bool = no_pagination + self.total_page: int = None # les lignes du tableau self.rows: list["RowAssiJusti"] = [] + # Titre du tableau, utilisé pour le cache + self.titre = titre + # Instanciation de la classe parent super().__init__( row_class=RowAssiJusti, @@ -65,59 +91,86 @@ class ListeAssiJusti(tb.Table): # 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 = self.table_data.assiduites_query - - # Non affichage des présences - if not self.options.show_pres: - assiduites_query_etudiants = assiduites_query_etudiants.filter( - Assiduite.etat != EtatAssiduite.PRESENT - ) - # Non affichage des retards - if not self.options.show_reta: - assiduites_query_etudiants = assiduites_query_etudiants.filter( - Assiduite.etat != EtatAssiduite.RETARD - ) - - if type_obj in [0, 2]: - justificatifs_query_etudiants = self.table_data.justificatifs_query - - # Combinaison des requêtes - - query_finale: Query = self.joindre( - query_assiduite=assiduites_query_etudiants, - query_justificatif=justificatifs_query_etudiants, + cle_cache: str = ":".join( + map( + str, + [ + self.titre, + type_obj, + self.options.show_pres, + self.options.show_reta, + self.options.order[0], + self.options.order[1], + ], + ) ) + r = RequeteTableauAssiduiteCache().get(cle_cache) + + if r is None: + if type_obj in [0, 1]: + assiduites_query_etudiants = self.table_data.assiduites_query + + # Non affichage des présences + if not self.options.show_pres: + assiduites_query_etudiants = assiduites_query_etudiants.filter( + Assiduite.etat != EtatAssiduite.PRESENT + ) + # Non affichage des retards + if not self.options.show_reta: + assiduites_query_etudiants = assiduites_query_etudiants.filter( + Assiduite.etat != EtatAssiduite.RETARD + ) + + if type_obj in [0, 2]: + justificatifs_query_etudiants = self.table_data.justificatifs_query + + # Combinaison des requêtes + + query_finale: Query = self.joindre( + query_assiduite=assiduites_query_etudiants, + query_justificatif=justificatifs_query_etudiants, + ) + + # Tri de la query si option + if self.options.order is not None: + order_sort: str = asc if self.options.order[1] else desc + order_col: str = self.options.order[0] + query_finale: Query = query_finale.order_by(order_sort(order_col)) + + r = query_finale.all() + RequeteTableauAssiduiteCache.set(cle_cache, r) # Paginer la requête pour ne pas envoyer trop d'informations au client - pagination: Pagination = self.paginer(query_finale) - self.total_pages: int = pagination.pages + pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination) + self.total_pages = pagination.total_pages # Générer les lignes de la page - for ligne in pagination.items: + 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: + def paginer(self, collection: list, no_pagination: bool = False) -> Pagination: """ - Applique la pagination à une requête SQLAlchemy en fonction des paramètres de la classe. + Applique une pagination à une collection en fonction des paramètres de la classe. - Cette méthode prend une requête SQLAlchemy et applique la pagination en utilisant les + Cette méthode prend une collection 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à + collection (list): La collection à paginer. Il s'agit par exemple 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 + Cette méthode ne modifie pas la collection originelle; elle renvoie plutôt un nouvel objet qui contient les résultats paginés. """ - return query.paginate( - page=self.options.page, per_page=self.options.nb_ligne_page, error_out=False + return Pagination( + collection, + self.options.page, + -1 if no_pagination else self.options.nb_ligne_page, ) def joindre(self, query_assiduite: Query = None, query_justificatif: Query = None): @@ -210,7 +263,7 @@ class ListeAssiJusti(tb.Table): # 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")) + query_combinee = db.session.query(query_combinee) return query_combinee @@ -241,30 +294,46 @@ class RowAssiJusti(tb.Row): # Type d'objet self._type() - # Date de début - multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date() # En excel, on export les "vraes dates". # En HTML, on écrit en français (on laisse les dates pour le tri) + + multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date() + + date_affichees: list[str] = [ + self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"), # date début + self.ligne["date_fin"].strftime("%d/%m/%y de %H:%M"), # date fin + ] + + if multi_days: + date_affichees[0] = self.ligne["date_debut"].strftime("%d/%m/%y") + date_affichees[1] = self.ligne["date_fin"].strftime("%d/%m/%y") + self.add_cell( "date_debut", "Date de début", - self.ligne["date_debut"].strftime("%d/%m/%y") - if multi_days - else self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"), + date_affichees[0], data={"order": self.ligne["date_debut"]}, raw_content=self.ligne["date_debut"], - column_classes={"date", "date-debut"}, + column_classes={ + "date", + "date-debut", + "external-sort", + "external-type:date_debut", + }, ) # Date de fin self.add_cell( "date_fin", "Date de fin", - self.ligne["date_fin"].strftime("%d/%m/%y") - if multi_days - else self.ligne["date_fin"].strftime("à %H:%M"), + date_affichees[1], raw_content=self.ligne["date_fin"], # Pour excel data={"order": self.ligne["date_fin"]}, - column_classes={"date", "date-fin"}, + column_classes={ + "date", + "date-fin", + "external-sort", + "external-type:date_fin", + }, ) # Ajout des colonnes optionnelles @@ -283,7 +352,11 @@ class RowAssiJusti(tb.Row): data={"order": self.ligne["entry_date"] or ""}, raw_content=self.ligne["entry_date"], classes=["small-font"], - column_classes={"entry_date"}, + column_classes={ + "entry_date", + "external-sort", + "external-type:entry_date", + }, ) def _type(self) -> None: @@ -541,6 +614,7 @@ class AssiDisplayOptions: show_etu: str | bool = True, show_actions: str | bool = True, show_module: str | bool = False, + order: tuple[str, str | bool] = None, ): self.page: int = page self.nb_ligne_page: int = nb_ligne_page @@ -554,6 +628,10 @@ class AssiDisplayOptions: self.show_actions = to_bool(show_actions) self.show_module = to_bool(show_module) + self.order = ( + ("date_debut", False) if order is None else (order[0], to_bool(order[1])) + ) + def remplacer(self, **kwargs): "Positionne options booléennes selon arguments" for k, v in kwargs.items(): @@ -565,6 +643,12 @@ class AssiDisplayOptions: self.nb_ligne_page = min( self.nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE ) + elif k == "order": + setattr( + self, + k, + ("date_debut", False) if v is None else (v[0], to_bool(v[1])), + ) class AssiJustifData: diff --git a/app/templates/assiduites/widgets/tableau.j2 b/app/templates/assiduites/widgets/tableau.j2 index 49325bc5c..423458a0a 100644 --- a/app/templates/assiduites/widgets/tableau.j2 +++ b/app/templates/assiduites/widgets/tableau.j2 @@ -1,6 +1,6 @@
{{ titre }}
-
+
{% if afficher_options != false %} @@ -17,33 +17,82 @@ {{scu.ICON_XLS|safe}}
{% endif %} - - - - - + {% for i in [25,50,100,1000] %} + {% if i == options.nb_ligne_page %} + + {% else %} + + {% endif %} {% endfor %}
+
+ {{table.html() | safe}} +
+ + + {% if total_pages > 1 %} +
    +
  • + < +
  • + +
  • + 1 +
  • + + + {% if options.page > 2 %} +
  • ...
  • + {% endif %} + + + {% for i in range(options.page - 1, options.page + 2) %} + {% if i > 1 and i < total_pages %} +
  • + {{ i }} +
  • + {% endif %} + {% endfor %} + + + {% if options.page < total_pages - 1 %} +
  • ...
  • + {% endif %} + + +
  • + {{ total_pages }} +
  • +
  • + > +
  • +
+ {% else %} + +
    +
  • 1
  • +
+ {% endif %} +
-{{table.html() | safe}} +
+ + + + diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 9b064d86a..fcca33d9d 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -324,6 +324,7 @@ def ajout_assiduite_etud() -> str | Response: 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 @@ -528,6 +529,7 @@ def liste_assiduites_etud(): afficher_etu=False, filtre=liste_assi.AssiFiltre(type_obj=0), options=liste_assi.AssiDisplayOptions(show_module=True), + cache_key=f"tableau-etud-{etudid}", ) if not tableau[0]: return tableau[1] @@ -697,6 +699,7 @@ def ajout_justificatif_etud(): 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 @@ -1442,6 +1445,7 @@ def _prepare_tableau( 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 @@ -1478,6 +1482,13 @@ def _prepare_tableau( 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() @@ -1488,14 +1499,21 @@ def _prepare_tableau( show_reta=show_reta, show_desc=show_desc, show_etu=afficher_etu, + order=ordre, ) + import time + + a = time.time() table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( table_data=data, options=options, filtre=filtre, + no_pagination=fmt.startswith("xls"), + titre=cache_key, ) - + b = time.time() + print(f"génération du tableau : {b-a:.6f}s") if fmt.startswith("xls"): return False, scu.send_file( table.excel(), @@ -1541,6 +1559,21 @@ def tableau_assiduite_actions(): 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]) + + flash(f"{objet_name} justifiée") + return redirect(request.referrer) # Justification d'une assiduité depuis le tableau if action == "justifier" and obj_type == "assiduite":