diff --git a/app/forms/assiduite/ajout_assiduite_etud.py b/app/forms/assiduite/ajout_assiduite_etud.py index deeec72c..d5783e2d 100644 --- a/app/forms/assiduite/ajout_assiduite_etud.py +++ b/app/forms/assiduite/ajout_assiduite_etud.py @@ -32,6 +32,7 @@ Formulaire ajout d'un justificatif sur un étudiant from flask_wtf import FlaskForm from flask_wtf.file import MultipleFileField from wtforms import ( + BooleanField, SelectField, StringField, SubmitField, @@ -136,6 +137,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm): "Module", choices={}, # will be populated dynamically ) + est_just = BooleanField("Justifiée") class AjoutJustificatifEtudForm(AjoutAssiOrJustForm): diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 3d6a296c..46584d9a 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 5f8287e6..7269654d 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -390,13 +390,11 @@ def get_assiduites_stats( # Récupération des états etats: list[str] = ( - filtered["etat"].split(",") - if "etat" in filtered - else ["absent", "present", "retard"] + filtered["etat"].split(",") if "etat" in filtered else scu.EtatAssiduite.all() ) # être sur que les états sont corrects - etats = [etat for etat in etats if etat in ["absent", "present", "retard"]] + etats = [etat for etat in etats if etat.upper() in scu.EtatAssiduite.all()] # Préparation du dictionnaire de retour avec les valeurs du calcul count: dict = calculator.to_dict(only_total=False) @@ -688,6 +686,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 +755,8 @@ 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) + + # Invalide les caches des tableaux de l'étudiant + sco_cache.RequeteTableauAssiduiteCache.delete_pattern( + pattern=f"tableau-etud-{etudid}:*" + ) diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index e31b6a18..e6d3fa81 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -396,3 +396,13 @@ class ValidationsSemestreCache(ScoDocCache): prefix = "VSC" timeout = 60 * 60 # ttl 1 heure (en phase de mise au point) + + +class RequeteTableauAssiduiteCache(ScoDocCache): + """ + clé : ":::>::" + Valeur = liste de dicts + """ + + prefix = "TABASSI" + timeout = 60 * 60 # Une heure diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 011038c2..4ba283e1 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -476,7 +476,7 @@ MONTH_NAMES_ABBREV = ( "Avr ", "Mai ", "Juin", - "Jul ", + "Juil ", "Août", "Sept", "Oct ", diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css index a19f0d9e..da872491 100644 --- a/app/static/css/assiduites.css +++ b/app/static/css/assiduites.css @@ -256,17 +256,17 @@ background-color: var(--color-conflit); } -.etud_row .assiduites_bar .absent, +.etud_row .assiduites_bar>.absent, .demo.absent { background-color: var(--color-absent) !important; } -.etud_row .assiduites_bar .present, +.etud_row .assiduites_bar>.present, .demo.present { background-color: var(--color-present) !important; } -.etud_row .assiduites_bar .retard, +.etud_row .assiduites_bar>.retard, .demo.retard { background-color: var(--color-retard) !important; } @@ -275,12 +275,12 @@ background-color: var(--color-nonwork) !important; } -.etud_row .assiduites_bar .justified, +.etud_row .assiduites_bar>.justified, .demo.justified { background-image: var(--motif-justi); } -.etud_row .assiduites_bar .invalid_justified, +.etud_row .assiduites_bar>.invalid_justified, .demo.invalid_justified { background-image: var(--motif-justi-invalide); } diff --git a/app/static/css/minitimeline.css b/app/static/css/minitimeline.css new file mode 100644 index 00000000..04c713c4 --- /dev/null +++ b/app/static/css/minitimeline.css @@ -0,0 +1,212 @@ +.day .dayline { + position: absolute; + display: none; + top: 100%; + z-index: 50; + width: max-content; + height: 75px; + background-color: #dedede; + border-radius: 15px; + padding: 5px; +} + +.day:hover .dayline { + display: block; +} + +.dayline .mini-timeline { + margin-top: 10%; +} + +.dayline-title { + margin: 0; +} + +.dayline .mini_tick { + position: absolute; + text-align: center; + top: 0; + transform: translateY(-110%); + z-index: 50; +} + +.dayline .mini_tick::after { + display: block; + content: "|"; + position: absolute; + bottom: -69%; + z-index: 2; + transform: translateX(200%); +} + +#label-nom, +#label-justi { + display: none; +} + +.demi .day { + display: flex; + justify-content: space-evenly; +} + +.demi .day>span { + display: block; + flex: 1; + text-align: center; + z-index: 1; + width: 100%; + border: 1px solid #d5d5d5; + position: relative; +} + +.demi .day>span:first-of-type { + width: 3em; + min-width: 3em; +} + +.options>* { + margin-right: 5px; +} + +.options input { + margin-right: 6px; +} + +.options label { + font-weight: normal; + margin-right: 16px; +} + + +/*Gestion des bubbles*/ +.assiduite-bubble { + position: relative; + display: none; + background-color: #f9f9f9; + border-radius: 5px; + padding: 8px; + border: 3px solid #ccc; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + font-size: 12px; + line-height: 1.4; + z-index: 3; + min-width: max-content; + top: 200%; +} + +.mini-timeline-block:hover .assiduite-bubble { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + height: auto; + max-height: 150px; +} + +.assiduite-bubble::before { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 6px; + border-style: solid; + border-color: transparent transparent #f9f9f9 transparent; +} + +.assiduite-bubble::after { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 5px; + border-style: solid; + border-color: transparent transparent #ccc transparent; +} + +.assiduite-id, +.assiduite-period, +.assiduite-state, +.assiduite-user_id { + margin-bottom: 4px; +} + +.assiduite-bubble.absent { + border-color: var(--color-absent) !important; +} + +.assiduite-bubble.present { + border-color: var(--color-present) !important; +} + +.assiduite-bubble.retard { + border-color: var(--color-retard) !important; +} + +/*Gestion des minitimelines*/ +.mini-timeline { + height: 7px; + border: 1px solid black; + position: relative; + background-color: white; +} + +.mini-timeline.single { + height: 9px; +} + +.mini-timeline-block { + position: absolute; + height: 100%; + display: flex; + justify-content: flex-start; + align-items: center; + flex-direction: column; +} + +.mini-timeline-block { + cursor: pointer; +} + +.mini_tick { + position: absolute; + text-align: start; + top: -40px; + transform: translateX(-50%); + z-index: 2; + +} + +.mini_tick::after { + display: block; + content: "|"; + position: absolute; + bottom: -2px; + z-index: 2; +} + +.mini-timeline-block.creneau { + outline: 3px solid var(--color-primary); + pointer-events: none; +} + +.mini-timeline-block.absent { + background-color: var(--color-absent) !important; +} + +.mini-timeline-block.present { + background-color: var(--color-present) !important; +} + +.mini-timeline-block.retard { + background-color: var(--color-retard) !important; +} + +.mini-timeline-block.justified { + background-image: var(--motif-justi); +} + +.mini-timeline-block.invalid_justified { + background-image: var(--motif-justi-invalide); +} \ No newline at end of file diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 9f7e69e7..b1522e7d 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -68,6 +68,25 @@ function setupCheckBox(parent = document) { }); } +function updateEtudList() { + const group_ids = getGroupIds(); + etuds = {}; + group_ids.forEach((group_id) => { + sync_get(getUrl() + `/api/group/${group_id}/etudiants`, (data, status) => { + if (status === "success") { + data.forEach((etud) => { + if (!(etud.id in etuds)) { + etuds[etud.id] = etud; + } + }); + } + }); + }); + + getAssiduitesFromEtuds(true); + generateAllEtudRow(); +} + /** * Validation préalable puis désactivation des chammps : * - Groupe @@ -108,14 +127,16 @@ function validateSelectors(btn) { return; } - getAssiduitesFromEtuds(true); - - // document.querySelector(".selectors").disabled = true; - // $("#tl_date").datepicker("option", "disabled", true); generateMassAssiduites(); + + getAssiduitesFromEtuds(true); generateAllEtudRow(); - // btn.remove(); - btn.textContent = "Actualiser"; + + btn.remove(); + // Auto actualisation + $("#tl_date").on("change", updateEtudList); + $("#group_ids_sel").on("change", updateEtudList); + onlyAbs(); }; @@ -648,16 +669,15 @@ function updateDate() { ); openAlertModal("Attention", div, "", "#eec660"); - /* BUG TODO MATHIAS - $(dateInput).datepicker("setDate", date_fra); // XXX ??? non définie - dateInput.value = date_fra; - */ date = lastWorkDay; dateStr = formatDate(lastWorkDay, { dateStyle: "full", timeZone: SCO_TIMEZONE, }).capitalize(); + + $(dateInput).datepicker("setDate", date); + $(dateInput).change(); } document.querySelector("#datestr").textContent = dateStr; diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index 17d04c27..8ad7992e 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -1,14 +1,74 @@ 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: + """ + Pagination d'une collection de données + + On donne : + - une collection de données (de préférence une liste / tuple) + - le numéro de page à afficher + - le nombre d'éléments par page + + On peut ensuite récupérer les éléments de la page courante avec la méthode `items()` + + Cette classe ne permet pas de changer de page. + (Pour cela, il faut créer une nouvelle instance, avec la collection originelle et la nouvelle page) + + l'intéret est de ne pas garder en mémoire toute la collection, mais seulement la page courante + + """ + + def __init__(self, collection: list, page: int = 1, per_page: int = -1): + """ + __init__ Instancie un nouvel objet Pagination + + Args: + collection (list): La collection à paginer. Il s'agit par exemple d'une requête + page (int, optional): le numéro de la page à voir. Defaults to 1. + per_page (int, optional): le nombre d'éléments par page. Defaults to -1. (-1 = pas de pagination/tout afficher) + """ + # par défaut le total des pages est 1 (même si la collection est vide) + self.total_pages = 1 + + if per_page != -1: + # on récupère le nombre de page complète et le reste + # q => nombre de page + # r => le nombre d'éléments restants (dernière page si != 0) + q, r = len(collection) // per_page, len(collection) % per_page + self.total_pages = q if r == 0 else q + 1 # q + 1 s'il reste des éléments + + # On s'assure que la page demandée est dans les limites + current_page: int = min(self.total_pages, page if page > 0 else 1) + + # On récupère la collection de la page courante + self.collection = ( + collection # toute la collection si pas de pagination + if per_page == -1 + else collection[ + per_page * (current_page - 1) : per_page * (current_page) + ] # sinon on récupère la page + ) + + def items(self) -> list: + """ + items Renvoi la collection de la page courante + + Returns: + list: la collection de la page courante + """ + return self.collection class ListeAssiJusti(tb.Table): @@ -18,13 +78,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 +103,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 +132,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 +304,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 +335,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 +393,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 +655,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 +669,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 +684,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/pages/ajout_assiduite_etud.j2 b/app/templates/assiduites/pages/ajout_assiduite_etud.j2 index 260ba5e4..7871bf90 100644 --- a/app/templates/assiduites/pages/ajout_assiduite_etud.j2 +++ b/app/templates/assiduites/pages/ajout_assiduite_etud.j2 @@ -87,6 +87,13 @@ div.submit > input { {{ form.modimpl }} {{ render_field_errors(form, 'modimpl') }} + {# Justifiée #} +
+ {{ form.est_just.label }} : + {{ form.est_just }} + génère un justificatif valide ayant la même période que l'assiduité signalée + {{ render_field_errors(form, 'est_just') }} +
{# Description #}
{{ form.description.label }}
diff --git a/app/templates/assiduites/pages/calendrier_assi_etud.j2 b/app/templates/assiduites/pages/calendrier_assi_etud.j2 index 3eaedb52..f0478610 100644 --- a/app/templates/assiduites/pages/calendrier_assi_etud.j2 +++ b/app/templates/assiduites/pages/calendrier_assi_etud.j2 @@ -1,4 +1,14 @@ -{% block pageContent %} +{% extends "sco_page.j2" %} +{% block title %} +Calendrier de l'assiduité +{% endblock title %} +{% block styles %} + {{ super() }} + + +{% endblock styles %} + +{% block app_content %} {% include "assiduites/widgets/alert.j2" %}
@@ -250,219 +260,6 @@ } - - .day .dayline { - position: absolute; - display: none; - top: 100%; - z-index: 50; - width: max-content; - height: 75px; - background-color: #dedede; - border-radius: 15px; - padding: 5px; - } - - .day:hover .dayline { - display: block; - } - - .dayline .mini-timeline { - margin-top: 10%; - } - - .dayline-title { - margin: 0; - } - - .dayline .mini_tick { - position: absolute; - text-align: center; - top: 0; - transform: translateY(-110%); - z-index: 50; - } - - .dayline .mini_tick::after { - display: block; - content: "|"; - position: absolute; - bottom: -69%; - z-index: 2; - transform: translateX(200%); - } - - #label-nom, - #label-justi { - display: none; - } - - .demi .day { - display: flex; - justify-content: space-evenly; - } - - .demi .day>span { - display: block; - flex: 1; - text-align: center; - z-index: 1; - width: 100%; - border: 1px solid #d5d5d5; - position: relative; - } - - .demi .day>span:first-of-type { - width: 3em; - min-width: 3em; - } - - .options>* { - margin-right: 5px; - } - - .options input { - margin-right: 6px; - } - - .options label { - font-weight: normal; - margin-right: 16px; - } - - - /*Gestion des bubbles*/ - .assiduite-bubble { - position: relative; - display: none; - background-color: #f9f9f9; - border-radius: 5px; - padding: 8px; - border: 3px solid #ccc; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - font-size: 12px; - line-height: 1.4; - z-index: 500; - min-width: max-content; - top: 200%; - } - - .mini-timeline-block:hover .assiduite-bubble { - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - } - - .assiduite-bubble::before { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - border-width: 6px; - border-style: solid; - border-color: transparent transparent #f9f9f9 transparent; - } - - .assiduite-bubble::after { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - border-width: 5px; - border-style: solid; - border-color: transparent transparent #ccc transparent; - } - - .assiduite-id, - .assiduite-period, - .assiduite-state, - .assiduite-user_id { - margin-bottom: 4px; - } - - .assiduite-bubble.absent { - border-color: var(--color-absent) !important; - } - - .assiduite-bubble.present { - border-color: var(--color-present) !important; - } - - .assiduite-bubble.retard { - border-color: var(--color-retard) !important; - } - - /*Gestion des minitimelines*/ - .mini-timeline { - height: 7px; - border: 1px solid black; - position: relative; - background-color: white; - } - - .mini-timeline.single { - height: 9px; - } - - .mini-timeline-block { - position: absolute; - height: 100%; - z-index: 1; - display: flex; - justify-content: flex-start; - align-items: center; - flex-direction: column; - } - - .mini-timeline-block { - cursor: pointer; - } - - .mini_tick { - position: absolute; - text-align: start; - top: -40px; - transform: translateX(-50%); - z-index: 50; - - } - - .mini_tick::after { - display: block; - content: "|"; - position: absolute; - bottom: -2px; - z-index: 2; - } - - .mini-timeline-block.creneau { - outline: 3px solid var(--color-primary); - pointer-events: none; - } - - .mini-timeline-block.absent { - background-color: var(--color-absent) !important; - } - - .mini-timeline-block.present { - background-color: var(--color-present) !important; - } - - .mini-timeline-block.retard { - background-color: var(--color-retard) !important; - } - - .mini-timeline-block.justified { - background-image: var(--motif-justi); - } - - .mini-timeline-block.invalid_justified { - background-image: var(--motif-justi-invalide); - } - @media print { .couleurs.print { @@ -593,4 +390,4 @@ -{% endblock pageContent %} +{% endblock app_content %} diff --git a/app/templates/assiduites/pages/signal_assiduites_group.j2 b/app/templates/assiduites/pages/signal_assiduites_group.j2 index 238dd25c..2ce3672e 100644 --- a/app/templates/assiduites/pages/signal_assiduites_group.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_group.j2 @@ -47,7 +47,6 @@ Faire la saisie {% endif %} -

Utilisez le bouton "Actualiser" si vous modifier la date ou le(s) groupe(s) sélectionné(s)

@@ -97,9 +96,7 @@ updateDate(); if (!readOnly){ setupTimeLine(()=>{ - if(document.querySelector('.etud_holder .placeholder') != null){ generateAllEtudRow(); - } }); } diff --git a/app/templates/assiduites/widgets/minitimeline.j2 b/app/templates/assiduites/widgets/minitimeline.j2 index 8671d74a..335ac701 100644 --- a/app/templates/assiduites/widgets/minitimeline.j2 +++ b/app/templates/assiduites/widgets/minitimeline.j2 @@ -73,11 +73,6 @@ updateSelectedSelect(getCurrentAssiduiteModuleImplId()); updateJustifyBtn(); } - try { - if (isCalendrier()) { - window.location = `liste_assiduites_etud?etudid=${etudid}&assiduite_id=${assiduité.assiduite_id}` - } - } catch { } }); //ajouter affichage assiduites on over setupAssiduiteBuble(block, assiduité); @@ -138,51 +133,43 @@ */ function setupAssiduiteBuble(el, assiduite) { if (!assiduite) return; - el.addEventListener("mouseenter", (event) => { - const bubble = document.querySelector(".assiduite-bubble"); - bubble.className = "assiduite-bubble"; - bubble.classList.add("is-active", assiduite.etat.toLowerCase()); - bubble.innerHTML = ""; + const bubble = document.createElement('div'); + bubble.className = "assiduite-bubble"; + bubble.classList.add(assiduite.etat.toLowerCase()); - const idDiv = document.createElement("div"); - idDiv.className = "assiduite-id"; - idDiv.textContent = `${getModuleImpl(assiduite)}`; - bubble.appendChild(idDiv); + const idDiv = document.createElement("div"); + idDiv.className = "assiduite-id"; + idDiv.textContent = `${getModuleImpl(assiduite)}`; + bubble.appendChild(idDiv); - const periodDivDeb = document.createElement("div"); - periodDivDeb.className = "assiduite-period"; - periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`; - bubble.appendChild(periodDivDeb); - const periodDivFin = document.createElement("div"); - periodDivFin.className = "assiduite-period"; - periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`; - bubble.appendChild(periodDivFin); + const periodDivDeb = document.createElement("div"); + periodDivDeb.className = "assiduite-period"; + periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`; + bubble.appendChild(periodDivDeb); + const periodDivFin = document.createElement("div"); + periodDivFin.className = "assiduite-period"; + periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`; + bubble.appendChild(periodDivFin); - const stateDiv = document.createElement("div"); - stateDiv.className = "assiduite-state"; - stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`; - bubble.appendChild(stateDiv); + const stateDiv = document.createElement("div"); + stateDiv.className = "assiduite-state"; + stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`; + bubble.appendChild(stateDiv); - const userIdDiv = document.createElement("div"); - userIdDiv.className = "assiduite-user_id"; - userIdDiv.textContent = `saisie le ${formatDateModal( - assiduite.entry_date, - " à " - )}`; + const userIdDiv = document.createElement("div"); + userIdDiv.className = "assiduite-user_id"; + userIdDiv.textContent = `saisie le ${formatDateModal( + assiduite.entry_date, + " à " + )}`; - if (assiduite.user_id != null) { - userIdDiv.textContent += `\npar ${assiduite.user_nom_complet}` - } - bubble.appendChild(userIdDiv); + if (assiduite.user_id != null) { + userIdDiv.textContent += `\npar ${assiduite.user_nom_complet}` + } + bubble.appendChild(userIdDiv); - bubble.style.left = `${event.clientX - bubble.offsetWidth / 2}px`; - bubble.style.top = `${event.clientY + 20}px`; - }); - el.addEventListener("mouseout", () => { - const bubble = document.querySelector(".assiduite-bubble"); - bubble.classList.remove("is-active"); - }); + el.appendChild(bubble); } function setMiniTick(timelineDate, dayStart, dayDuration) { @@ -198,127 +185,4 @@ return tick } - - - + \ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau.j2 b/app/templates/assiduites/widgets/tableau.j2 index 49325bc5..3691882d 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,84 @@ {{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 and (options.page - 1) - 1 > 1 %} +
  • ...
  • + {% 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 and total_pages - (options.page + 1 ) > 1 %} +
  • ...
  • + {% endif %} + + +
  • + {{ total_pages }} +
  • +
  • + > +
  • +
+ {% else %} + +
    +
  • 1
  • +
+ {% endif %} +
-{{table.html() | safe}} +
+ + + + diff --git a/app/templates/assiduites/widgets/timeline.j2 b/app/templates/assiduites/widgets/timeline.j2 index 24338b0c..754fb2da 100644 --- a/app/templates/assiduites/widgets/timeline.j2 +++ b/app/templates/assiduites/widgets/timeline.j2 @@ -89,8 +89,7 @@ } - function timelineMainEvent(event, callback) { - const func_call = callback ? callback : () => { }; + function timelineMainEvent(event) { const startX = (event.clientX || event.changedTouches[0].clientX); @@ -152,7 +151,6 @@ updatePeriodTimeLabel(); }; const mouseUp = () => { - generateAllEtudRow(); snapHandlesToQuarters(); timelineContainer.removeEventListener("mousemove", onMouseMove); func_call(); @@ -172,9 +170,12 @@ } } + let func_call = () => { }; + function setupTimeLine(callback) { - timelineContainer.addEventListener("mousedown", (e) => { timelineMainEvent(e, callback) }); - timelineContainer.addEventListener("touchstart", (e) => { timelineMainEvent(e, callback) }); + func_call = callback; + timelineContainer.addEventListener("mousedown", (e) => { timelineMainEvent(e) }); + timelineContainer.addEventListener("touchstart", (e) => { timelineMainEvent(e) }); } function adjustPeriodPosition(newLeft, newWidth) { @@ -230,8 +231,8 @@ periodTimeLine.style.width = `${widthPercentage}%`; snapHandlesToQuarters(); - generateAllEtudRow(); updatePeriodTimeLabel() + func_call(); } function snapHandlesToQuarters() { @@ -270,7 +271,6 @@ if (heure_deb != '' && heure_fin != '') { heure_deb = fromTime(heure_deb); heure_fin = fromTime(heure_fin); - console.warn(heure_deb, heure_fin) setPeriodValues(heure_deb, heure_fin) } {% endif %} diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 33f9277b..96e40021 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 @@ -461,11 +462,13 @@ def _record_assiduite_etud( 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, - scu.EtatAssiduite.get(form.assi_etat.data), + assi_etat, description=form.description.data, entry_date=dt_entry_date_tz_server, external_data=external_data, @@ -476,6 +479,19 @@ def _record_assiduite_etud( 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) @@ -524,10 +540,11 @@ def liste_assiduites_etud(): liste_assi.AssiJustifData.from_etudiants( etud, ), - filename=f"assiduites-justificatifs-{etudid}", + 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] @@ -697,6 +714,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 @@ -860,36 +878,20 @@ def calendrier_assi_etud(): annees_str += f"{ann}," annees_str += "]" - # 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", - ], - ) + calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee) - calendrier = generate_calendar(etud, annee) # Peuplement du template jinja - return HTMLBuilder( - header, - 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, - ), - ).build() + 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"]) @@ -924,7 +926,9 @@ def choix_date() -> str: if ok: return redirect( url_for( - "assiduites.signal_assiduites_group", + "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, @@ -1076,6 +1080,7 @@ def signal_assiduites_group(): cssstyles=CSSSTYLES + [ "css/assiduites.css", + "css/minitimeline.css", ], ) @@ -1173,13 +1178,19 @@ def visu_assiduites_group(): ] # --- 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() + 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: @@ -1223,6 +1234,7 @@ def visu_assiduites_group(): cssstyles=CSSSTYLES + [ "css/assiduites.css", + "css/minitimeline.css", ], ) @@ -1450,6 +1462,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 @@ -1486,6 +1499,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() @@ -1496,12 +1516,15 @@ def _prepare_tableau( 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"): @@ -2297,7 +2320,7 @@ def _get_etuds_dem_def(formsemestre) -> str: 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() @@ -2321,7 +2344,7 @@ def generate_calendar( ) # Récupération des jours de l'année et de leurs assiduités/justificatifs - annee_par_mois: dict[int, list[datetime.date]] = _organize_by_month( + annee_par_mois: dict[str, list[Jour]] = _organize_by_month( _get_dates_between( deb=date_debut.date(), fin=date_fin.date(), @@ -2333,32 +2356,6 @@ def generate_calendar( return annee_par_mois -WEEKDAYS = { - 0: "Lun ", - 1: "Mar ", - 2: "Mer ", - 3: "Jeu ", - 4: "Ven ", - 5: "Sam ", - 6: "Dim ", -} - -MONTHS = { - 1: "Janv.", - 2: "Févr.", - 3: "Mars", - 4: "Avr.", - 5: "Mai", - 6: "Juin", - 7: "Juil.", - 8: "Août", - 9: "Sept.", - 10: "Oct.", - 11: "Nov.", - 12: "Déc.", -} - - class Jour: """Jour Jour du calendrier @@ -2371,8 +2368,8 @@ class Jour: self.justificatifs = justificatifs def get_nom(self, mode_demi: bool = True) -> str: - str_jour: str = WEEKDAYS.get(self.date.weekday()) - return f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour}{self.date.day}" + 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") @@ -2584,14 +2581,14 @@ def _get_dates_between(deb: datetime.date, fin: datetime.date) -> list[datetime. return resultat -def _organize_by_month(days, assiduites, justificatifs): +def _organize_by_month(days, assiduites, justificatifs) -> dict[str, list[Jour]]: """ Organiser les dates par mois. """ organized = {} for date in days: - # Utiliser le numéro du mois comme clé - month = MONTHS.get(date.month) + # 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] = []