Assiduites : pagination + tri + options tableaux

This commit is contained in:
Iziram 2024-01-11 17:24:01 +01:00
parent 76bedfb303
commit 023e3a4c04
7 changed files with 380 additions and 63 deletions

View File

@ -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(

View File

@ -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}")

View File

@ -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é : "<titre_tableau>:<type_obj>:<show_pres>:<show_retard>>:<order_col>:<order>"
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 <start>"""
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],
)

View File

@ -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;

View File

@ -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,6 +91,22 @@ class ListeAssiJusti(tb.Table):
# Récupération du filtrage des objets -> 0 : tout, 1 : Assi, 2: Justi
type_obj = self.filtre.type_obj()
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
@ -89,35 +131,46 @@ class ListeAssiJusti(tb.Table):
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:

View File

@ -1,6 +1,6 @@
<div>
<div class="sco_box_title">{{ titre }}</div>
<div id="options-tableau">
<div class="options-tableau">
{% if afficher_options != false %}
<input type="checkbox" id="show_pres" name="show_pres"
onclick="updateTableau()" {{'checked' if options.show_pres else ''}}>
@ -17,33 +17,82 @@
<a style="margin-left:32px;" href="{{request.url}}&fmt=xlsx">{{scu.ICON_XLS|safe}}</a>
<br>
{% endif %}
<label for="nb_ligne_page">Nombre de lignes par page : </label>
<input type="number" name="nb_ligne_page" id="nb_ligne_page"
size="4" step="25" min="10" value="{{options.nb_ligne_page}}"
onchange="updateTableau()"
>
<label for="n_page">Page n°</label>
<select name="n_page" id="n_page">
{% for n in range(1,total_pages+1) %}
<option value="{{n}}" {{'selected' if n == options.page else ''}}>{{n}}</option>
<label for="nb_ligne_page">Nombre de lignes par page :</label>
<select name="nb_ligne_page" id="nb_ligne_page" onchange="updateTableau()">
{% for i in [25,50,100,1000] %}
{% if i == options.nb_ligne_page %}
<option selected value="{{i}}">{{i}}</option>
{% else %}
<option value="{{i}}">{{i}}</option>
{% endif %}
{% endfor %}
</select>
<br>
</div>
<div class="div-tableau">
{{table.html() | safe}}
<div class="options-tableau">
<!--Pagination basée sur : https://app.uxcel.com/courses/ui-components-best-practices/best-practices-005 -->
<!-- Mettre les flèches -->
{% if total_pages > 1 %}
<ul class="pagination">
<li class="">
<a onclick="navigateToPage({{options.page - 1}})">&lt;</a>
</li>
<!-- Toujours afficher la première page -->
<li class="{% if options.page == 1 %}active{% endif %}">
<a onclick="navigateToPage({{1}})">1</a>
</li>
<!-- Afficher les ellipses si la page courante est supérieure à 2 -->
{% if options.page > 2 %}
<li class="disabled"><span>...</span></li>
{% endif %}
<!-- Afficher la page précédente, la page courante, et la page suivante -->
{% for i in range(options.page - 1, options.page + 2) %}
{% if i > 1 and i < total_pages %}
<li class="{% if options.page == i %}active{% endif %}">
<a onclick="navigateToPage({{i}})">{{ i }}</a>
</li>
{% endif %}
{% endfor %}
<!-- Afficher les ellipses si la page courante est inférieure à l'avant-dernière page -->
{% if options.page < total_pages - 1 %}
<li class="disabled"><span>...</span></li>
{% endif %}
<!-- Toujours afficher la dernière page -->
<li class="{% if options.page == total_pages %}active{% endif %}">
<a onclick="navigateToPage({{total_pages}})">{{ total_pages }}</a>
</li>
<li class="">
<a onclick="navigateToPage({{options.page + 1}})">&gt;</a>
</li>
</ul>
{% else %}
<!-- Afficher un seul bouton si il n'y a qu'une seule page -->
<ul class="pagination">
<li class="active"><a onclick="navigateToPage({{1}})">1</a></li>
</ul>
{% endif %}
</div>
</div>
{{table.html() | safe}}
</div>
<script>
function updateTableau() {
const url = new URL(location.href);
const form = document.getElementById("options-tableau");
const formValues = form.querySelectorAll("*[name]");
const formValues = document.querySelectorAll(".options-tableau *[name]");
formValues.forEach((el) => {
if (el.type == "checkbox") {
url.searchParams.set(el.name, el.checked)
@ -58,10 +107,56 @@
}
}
const total_pages = {{total_pages}};
function navigateToPage(pageNumber){
if(pageNumber > total_pages || pageNumber < 1) return;
const url = new URL(location.href);
url.searchParams.set("n_page", pageNumber)
if (!url.href.endsWith("#options-tableau")) {
location.href = url.href + "#options-tableau";
} else {
location.href = url.href;
}
}
window.addEventListener('load', ()=>{
const table_columns = [...document.querySelectorAll('.external-sort')];
table_columns.forEach((e)=>e.addEventListener('click', ()=>{
// récupération de l'ordre "ascending" / "descending"
let order = e.ariaSort;
// récupération de la colonne à ordonner
// il faut avoir une classe `external-type:<NOM COL>`
let order_col = e.className.split(" ").find((e)=>e.indexOf("external-type:") != -1);
//Création de la nouvelle url avec le tri
const url = new URL(location.href);
url.searchParams.set("order", order);
url.searchParams.set("order_col", order_col.split(":")[1]);
location.href = url.href
}));
});
</script>
<style>
.small-font {
font-size: 9pt;
}
.div-tableau{
display: flex;
flex-direction: column;
align-items: center;
max-width: fit-content;
}
.pagination li{
cursor: pointer;
}
</style>

View File

@ -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":