From 0634dbd0aa1fab8499e4aeb69f61f53300ed22b0 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 15 Jan 2024 17:49:28 +0100 Subject: [PATCH 1/7] Cache: delete_pattern --- app/scodoc/sco_cache.py | 27 +++++++++++++++++++++++++++ sco_version.py | 2 +- tests/unit/test_caches.py | 4 ++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index 4c9960df..e31b6a18 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -121,6 +121,33 @@ class ScoDocCache: for oid in oids: cls.delete(oid) + @classmethod + def delete_pattern(cls, pattern: str, std_prefix=True) -> int: + """Delete all keys matching pattern. + The pattern starts with flask_cache_. + If std_prefix is true (default), the prefix is added + to the given pattern. + Examples: + 'TABASSI_tableau-etud-1234:*' + Or, with std_prefix false, 'flask_cache_RT_TABASSI_tableau-etud-1234:*' + + Returns number of keys deleted. + """ + # see https://stackoverflow.com/questions/36708461/flask-cache-list-keys-based-on-a-pattern + assert CACHE.cache.__class__.__name__ == "RedisCache" # Redis specific + import redis + + if std_prefix: + pattern = "flask_cache_" + g.scodoc_dept + "_" + cls.prefix + "_" + pattern + + r = redis.Redis() + count = 0 + for key in r.scan_iter(pattern): + log(f"{cls.__name__}.delete_pattern({key})") + r.delete(key) + count += 1 + return count + class EvaluationCache(ScoDocCache): """Cache for evaluations. diff --git a/sco_version.py b/sco_version.py index 53da13f9..7b986ac6 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.77" +SCOVERSION = "9.6.78" SCONAME = "ScoDoc" diff --git a/tests/unit/test_caches.py b/tests/unit/test_caches.py index 3a4882d7..0e3766ba 100644 --- a/tests/unit/test_caches.py +++ b/tests/unit/test_caches.py @@ -48,6 +48,10 @@ def test_notes_table(test_client): # XXX A REVOIR POUR TESTER RES TODO formsemestre_id = sem["formsemestre_id"] nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) assert sco_cache.ResultatsSemestreCache.get(formsemestre_id) + # Efface les semestres + sco_cache.ResultatsSemestreCache.delete_pattern("*") + for sem in sems[:10]: + assert sco_cache.ResultatsSemestreCache.get(formsemestre_id) is None def test_cache_evaluations(test_client): From 76bedfb303e506b8c5e96924e094debd1fe57492 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 15 Jan 2024 18:57:52 +0100 Subject: [PATCH 2/7] =?UTF-8?q?Fix:=20bug=20synchro=20apo=20si=201=20seul?= =?UTF-8?q?=20=C3=A9tudiant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_portal_apogee.py | 4 +++- app/scodoc/sco_synchro_etuds.py | 2 +- tools/fakeportal/fakeportal.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/scodoc/sco_portal_apogee.py b/app/scodoc/sco_portal_apogee.py index bfec3eb5..9a46edb2 100644 --- a/app/scodoc/sco_portal_apogee.py +++ b/app/scodoc/sco_portal_apogee.py @@ -149,7 +149,9 @@ get_maquette_url = _PI.get_maquette_url get_portal_api_version = _PI.get_portal_api_version -def get_inscrits_etape(code_etape, annee_apogee=None, ntrials=4, use_cache=True): +def get_inscrits_etape( + code_etape, annee_apogee=None, ntrials=4, use_cache=True +) -> list[dict]: """Liste des inscrits à une étape Apogée Result = list of dicts ntrials: try several time the same request, useful for some bad web services diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index 9982dfba..dca27e64 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -132,7 +132,7 @@ def formsemestre_synchro_etuds( if isinstance(etuds, str): etuds = etuds.split(",") # vient du form de confirmation elif isinstance(etuds, int): - etuds = [etuds] + etuds = [str(etuds)] if isinstance(inscrits_without_key, int): inscrits_without_key = [inscrits_without_key] elif isinstance(inscrits_without_key, str): diff --git a/tools/fakeportal/fakeportal.py b/tools/fakeportal/fakeportal.py index 7efff4d3..785f5856 100755 --- a/tools/fakeportal/fakeportal.py +++ b/tools/fakeportal/fakeportal.py @@ -4,7 +4,7 @@ emulating "Apogee" Web service Usage: - /opt/scodoc/tools/fakeportal/fakeportal.py + /opt/scodoc/tools/fakeportal/fakeportal.py et régler "URL du portail" sur la page de *Paramétrage* du département testé, typiquement: http://localhost:8678 From 023e3a4c0418a8dd0fcdcc7a58c492d175d8085c Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 11 Jan 2024 17:24:01 +0100 Subject: [PATCH 3/7] 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 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..c252b34e 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 e31b6a18..8d0a5da8 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 9f7e69e7..59aefad1 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 17d04c27..1a261376 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 49325bc5..423458a0 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 9b064d86..fcca33d9 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": From 3a3f94b7cf6cf6e9895b09a6d3d3c1ed222757cc Mon Sep 17 00:00:00 2001 From: Iziram Date: Tue, 16 Jan 2024 09:19:40 +0100 Subject: [PATCH 4/7] =?UTF-8?q?Assiduites=20:=20fin=20int=C3=A9gration=20p?= =?UTF-8?q?agination=20+=20cache=20tableau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_assiduites.py | 5 ++- app/scodoc/sco_cache.py | 43 ------------------ app/static/js/assiduites.js | 48 -------------------- app/tables/liste_assiduites.py | 49 +++++++++++++++++++-- app/templates/assiduites/widgets/tableau.j2 | 6 ++- app/views/assiduites.py | 10 ++--- 6 files changed, 56 insertions(+), 105 deletions(-) diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index c252b34e..b3136635 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -758,4 +758,7 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None): invalidate_assiduites_etud_date(etudid, date_debut) invalidate_assiduites_etud_date(etudid, date_fin) - sco_cache.RequeteTableauAssiduiteCache.delete_with(f"tableau-etud-{etudid}") + # 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 8d0a5da8..e6d3fa81 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -398,10 +398,6 @@ class ValidationsSemestreCache(ScoDocCache): timeout = 60 * 60 # ttl 1 heure (en phase de mise au point) -class SimpleIndexCache(ScoDocCache): - prefix = "INDEX" - - class RequeteTableauAssiduiteCache(ScoDocCache): """ clé : ":::>::" @@ -410,42 +406,3 @@ class RequeteTableauAssiduiteCache(ScoDocCache): 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 59aefad1..9f7e69e7 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1,51 +1,3 @@ -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 1a261376..8ad7992e 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -13,20 +13,61 @@ 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 - current_page: int = min(self.total_pages, 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 + collection # toute la collection si pas de pagination if per_page == -1 - else collection[per_page * (current_page - 1) : per_page * (current_page)] + 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 diff --git a/app/templates/assiduites/widgets/tableau.j2 b/app/templates/assiduites/widgets/tableau.j2 index 423458a0..3691882d 100644 --- a/app/templates/assiduites/widgets/tableau.j2 +++ b/app/templates/assiduites/widgets/tableau.j2 @@ -47,7 +47,8 @@ - {% if options.page > 2 %} + + {% if options.page > 2 and (options.page - 1) - 1 > 1 %}
  • ...
  • {% endif %} @@ -61,7 +62,8 @@ {% endfor %} - {% if options.page < total_pages - 1 %} + + {% if options.page < total_pages - 1 and total_pages - (options.page + 1 ) > 1 %}
  • ...
  • {% endif %} diff --git a/app/views/assiduites.py b/app/views/assiduites.py index fcca33d9..f9f2887a 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -525,11 +525,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-{etudid}", + cache_key=f"tableau-etud-{etud.id}", ) if not tableau[0]: return tableau[1] @@ -1501,9 +1501,6 @@ def _prepare_tableau( show_etu=afficher_etu, order=ordre, ) - import time - - a = time.time() table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( table_data=data, @@ -1512,8 +1509,7 @@ def _prepare_tableau( 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(), From 2c42a1547c339ea788a1861b95aa780d45fe0aec Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 16 Jan 2024 11:11:00 +0100 Subject: [PATCH 5/7] =?UTF-8?q?Am=C3=A9liore=20moduleimpl=5Finscriptions?= =?UTF-8?q?=5Fedit.=20Closes=20#843?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/formsemestre.py | 2 +- app/models/moduleimpls.py | 18 +++++ app/scodoc/sco_moduleimpl_inscriptions.py | 99 +++++++++++------------ app/static/css/scodoc.css | 4 +- sco_version.py | 2 +- 5 files changed, 72 insertions(+), 53 deletions(-) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 4506eed0..32295816 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -187,7 +187,7 @@ class FormSemestre(db.Model): def get_formsemestre( cls, formsemestre_id: int | str, dept_id: int = None ) -> "FormSemestre": - """ "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant""" + """FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant""" if not isinstance(formsemestre_id, int): try: formsemestre_id = int(formsemestre_id) diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index b674ed99..9cb168eb 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -2,6 +2,7 @@ """ScoDoc models: moduleimpls """ import pandas as pd +from flask import abort, g from flask_sqlalchemy.query import Query from app import db @@ -82,6 +83,23 @@ class ModuleImpl(db.Model): df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids) return evaluations_poids + @classmethod + def get_modimpl(cls, moduleimpl_id: int | str, dept_id: int = None) -> "ModuleImpl": + """FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant.""" + from app.models.formsemestre import FormSemestre + + if not isinstance(moduleimpl_id, int): + try: + moduleimpl_id = int(moduleimpl_id) + except (TypeError, ValueError): + abort(404, "moduleimpl_id invalide") + if g.scodoc_dept: + dept_id = dept_id if dept_id is not None else g.scodoc_dept_id + query = cls.query.filter_by(id=moduleimpl_id) + if dept_id is not None: + query = query.join(FormSemestre).filter_by(dept_id=dept_id) + return query.first_or_404() + def invalidate_evaluations_poids(self): """Invalide poids cachés""" df_cache.EvaluationsPoidsCache.delete(self.id) diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 34b3d3bd..487368f0 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -40,6 +40,7 @@ from app.comp.res_compat import NotesTableCompat from app.models import ( FormSemestre, Identite, + ModuleImpl, Partition, ScolarFormSemestreValidation, UniteEns, @@ -52,7 +53,6 @@ from app.scodoc import codes_cursus from app.scodoc import sco_edit_module from app.scodoc import sco_edit_ue from app.scodoc import sco_etud -from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl @@ -63,7 +63,9 @@ import app.scodoc.sco_utils as scu from app.tables import list_etuds -def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): +def moduleimpl_inscriptions_edit( + moduleimpl_id, etudids: list[int] | None = None, submitted=False +): """Formulaire inscription des etudiants a ce module * Gestion des inscriptions Nom TD TA TP (triable) @@ -75,12 +77,12 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): * Si pas les droits: idem en readonly """ - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] - formsemestre_id = M["formsemestre_id"] - mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - sem = sco_formsemestre.get_formsemestre(formsemestre_id) + etudids = etudids or [] + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) + module = modimpl.module + formsemestre = modimpl.formsemestre # -- check lock - if not sem["etat"]: + if not formsemestre.etat: raise ScoValueError("opération impossible: semestre verrouille") header = html_sco_header.sco_header( page_title="Inscription au module", @@ -90,25 +92,23 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): footer = html_sco_header.sco_footer() H = [ header, - """

    Inscriptions au module %s (%s)

    + f"""

    Inscriptions au module {module.titre or "(module sans titre)"} ({module.code})

    Cette page permet d'éditer les étudiants inscrits à ce module (ils doivent évidemment être inscrits au semestre). - Les étudiants cochés sont (ou seront) inscrits. Vous pouvez facilement inscrire ou + Les étudiants cochés sont (ou seront) inscrits. Vous pouvez inscrire ou désinscrire tous les étudiants d'un groupe à l'aide des menus "Ajouter" et "Enlever".

    -

    Aucune modification n'est prise en compte tant que l'on n'appuie pas sur le bouton - "Appliquer les modifications". +

    Aucune modification n'est prise en compte tant que l'on n'appuie pas + sur le bouton "Appliquer les modifications".

    - """ - % ( - moduleimpl_id, - mod["titre"] or "(module sans titre)", - mod["code"] or "(module sans code)", - ), + """, ] # Liste des inscrits à ce semestre inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( - formsemestre_id + formsemestre.id ) for ins in inscrits: etuds_info = sco_etud.get_etud_info(etudid=ins["etudid"], filled=1) @@ -121,12 +121,10 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): ) ins["etud"] = etuds_info[0] inscrits.sort(key=lambda inscr: sco_etud.etud_sort_key(inscr["etud"])) - in_m = sco_moduleimpl.do_moduleimpl_inscription_list( - moduleimpl_id=M["moduleimpl_id"] - ) - in_module = set([x["etudid"] for x in in_m]) + in_m = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=modimpl.id) + in_module = {x["etudid"] for x in in_m} # - partitions = sco_groups.get_partitions_list(formsemestre_id) + partitions = sco_groups.get_partitions_list(formsemestre.id) # if not submitted: H.append( @@ -149,27 +147,32 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): } } - """ + + + """ ) H.append( f"""
    - + -

    - - { _make_menu(partitions, "Ajouter", "true") } - { _make_menu(partitions, "Enlever", "false")} -
    -


    - +
    + { _make_menu(partitions, "Ajouter", "true") } + { _make_menu(partitions, "Enlever", "false")} +
    +
    + - + """ ) for partition in partitions: if partition["partition_name"]: - H.append("" % partition["partition_name"]) - H.append("") + H.append(f"") + H.append("") for ins in inscrits: etud = ins["etud"] @@ -178,24 +181,20 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): else: checked = "" H.append( - """""") - groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre_id) + groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre.id) for partition in partitions: if partition["partition_name"]: gr_name = "" @@ -205,11 +204,11 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): break # gr_name == '' si etud non inscrit dans un groupe de cette partition H.append(f"") - H.append("""
    NomNom%s
    {partition['partition_name']}
    """ - % (etud["etudid"], checked) + f"""
    """ ) H.append( - """%s""" - % ( + f"""{etud['nomprenom']}""" ) H.append("""{gr_name}
    """) + H.append("""""") else: # SUBMISSION # inscrit a ce module tous les etuds selectionnes sco_moduleimpl.do_moduleimpl_inscrit_etuds( - moduleimpl_id, formsemestre_id, etuds, reset=True + moduleimpl_id, formsemestre.id, etudids, reset=True ) return flask.redirect( url_for( @@ -225,10 +224,10 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): def _make_menu(partitions: list[dict], title="", check="true") -> str: """Menu with list of all groups""" - items = [{"title": "Tous", "attr": "onclick=\"group_select('', -1, %s)\"" % check}] + items = [{"title": "Tous", "attr": f"onclick=\"group_select('', -1, {check})\""}] p_idx = 0 for partition in partitions: - if partition["partition_name"] != None: + if partition["partition_name"] is not None: p_idx += 1 for group in sco_groups.get_partition_groups(partition): items.append( @@ -240,9 +239,9 @@ def _make_menu(partitions: list[dict], title="", check="true") -> str: } ) return ( - '' + '
    ' + htmlutils.make_menu(title, items, alone=True) - + "" + + "
    " ) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index f42cc0f6..ac2c691a 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1736,7 +1736,9 @@ formsemestre_page_title .lock img { width: 200px !important; } -span.inscr_addremove_menu { +div.inscr_addremove_menu { + display: inline-block; + margin: 8px 0px; width: 150px; } diff --git a/sco_version.py b/sco_version.py index 7b986ac6..6535cd67 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.78" +SCOVERSION = "9.6.79" SCONAME = "ScoDoc" From 0cafc0b1841b3f3d24fe1af44f17def9fe85036a Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 16 Jan 2024 12:36:20 +0100 Subject: [PATCH 6/7] Ajoute timepicker partout. Utilise pour evaluation_edit. Fix #829 --- app/models/evaluations.py | 18 ++++---------- app/scodoc/TrivialFormulator.py | 5 ++++ app/scodoc/html_sco_header.py | 24 ++++++++++++++----- app/scodoc/sco_evaluation_edit.py | 9 +++---- app/scodoc/sco_moduleimpl_status.py | 1 + .../assiduites/pages/ajout_assiduite_etud.j2 | 15 +----------- .../assiduites/pages/ajout_assiduites.j2 | 13 +--------- .../pages/ajout_justificatif_etud.j2 | 15 +----------- app/templates/assiduites/pages/choix_date.j2 | 9 ------- app/templates/sco_page.j2 | 3 +++ app/templates/sco_timepicker.j2 | 12 ++++++++++ 11 files changed, 49 insertions(+), 75 deletions(-) create mode 100644 app/templates/sco_timepicker.j2 diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 3dcac667..0c7d1213 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -584,20 +584,10 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict): if date_debut and date_fin: duration = data["date_fin"] - data["date_debut"] if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION: - raise ScoValueError("Heures de l'évaluation incohérentes !") - # # --- heures - # heure_debut = data.get("heure_debut", None) - # if heure_debut and not isinstance(heure_debut, datetime.time): - # if date_format == "dmy": - # data["heure_debut"] = heure_to_time(heure_debut) - # else: # ISO - # data["heure_debut"] = datetime.time.fromisoformat(heure_debut) - # heure_fin = data.get("heure_fin", None) - # if heure_fin and not isinstance(heure_fin, datetime.time): - # if date_format == "dmy": - # data["heure_fin"] = heure_to_time(heure_fin) - # else: # ISO - # data["heure_fin"] = datetime.time.fromisoformat(heure_fin) + raise ScoValueError( + "Heures de l'évaluation incohérentes !", + dest_url="javascript:history.back();", + ) def heure_to_time(heure: str) -> datetime.time: diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index de061800..944566a0 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -685,6 +685,11 @@ class TF(object): '' % (field, values[field]) ) + elif input_type == "time": # JavaScript widget for date input + lem.append( + f"""""" + ) elif input_type == "text_suggest": lem.append( '\n' + f""" + + + """ ) if init_google_maps: # It may be necessary to add an API key: @@ -219,19 +224,26 @@ def sco_header( # jQuery H.append( - f""" - """ + f""" + + + """ ) # qTip if init_qtip: H.append( f""" - """ + + """ ) H.append( - f""" - """ + f""" + + + """ ) if init_google_maps: H.append( diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index 9e0c487f..9b5c716f 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -178,9 +178,7 @@ def evaluation_create_form( { "title": "Heure de début", "explanation": "heure du début de l'épreuve", - "input_type": "menu", - "allowed_values": heures, - "labels": heures, + "input_type": "time", }, ), ( @@ -188,9 +186,7 @@ def evaluation_create_form( { "title": "Heure de fin", "explanation": "heure de fin de l'épreuve", - "input_type": "menu", - "allowed_values": heures, - "labels": heures, + "input_type": "time", }, ), ] @@ -335,6 +331,7 @@ def evaluation_create_form( + "\n" + tf[1] + render_template("scodoc/help/evaluations.j2", is_apc=is_apc) + + render_template("sco_timepicker.j2") + html_sco_header.sco_footer() ) elif tf[0] == -1: diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index d3f83c28..5f01a3bf 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -167,6 +167,7 @@ def _ue_coefs_html(coefs_lst) -> str: {'background-color: ' + ue.color + ';' if ue.color else ''} ">
    {coef}
    {ue.acronyme}
    """ for ue, coef in coefs_lst + if coef > 0 ] ) + "" diff --git a/app/templates/assiduites/pages/ajout_assiduite_etud.j2 b/app/templates/assiduites/pages/ajout_assiduite_etud.j2 index cdc3dc7c..260ba5e4 100644 --- a/app/templates/assiduites/pages/ajout_assiduite_etud.j2 +++ b/app/templates/assiduites/pages/ajout_assiduite_etud.j2 @@ -6,7 +6,6 @@ {% block styles %} {{super()}} - {% endblock %} @@ -114,19 +113,7 @@ div.submit > input { {% block scripts %} {{ super() }} - - +{% include "sco_timepicker.j2" %} {% endblock scripts %} diff --git a/app/templates/assiduites/pages/ajout_assiduites.j2 b/app/templates/assiduites/pages/ajout_assiduites.j2 index 9326d930..85b7697a 100644 --- a/app/templates/assiduites/pages/ajout_assiduites.j2 +++ b/app/templates/assiduites/pages/ajout_assiduites.j2 @@ -97,19 +97,8 @@ color: var(--color-error); } +{% include "sco_timepicker.j2" %} - +{% include "sco_timepicker.j2" %} -{% endblock scripts %} \ No newline at end of file diff --git a/app/templates/sco_page.j2 b/app/templates/sco_page.j2 index 67ab61b3..f2bc9425 100644 --- a/app/templates/sco_page.j2 +++ b/app/templates/sco_page.j2 @@ -5,6 +5,8 @@ {{super()}} + @@ -45,6 +47,7 @@ + diff --git a/app/templates/sco_timepicker.j2 b/app/templates/sco_timepicker.j2 new file mode 100644 index 00000000..de0e0123 --- /dev/null +++ b/app/templates/sco_timepicker.j2 @@ -0,0 +1,12 @@ + From 3e1f563ecd828f5d43e0f9e5abf1fa3a8d9ea3c9 Mon Sep 17 00:00:00 2001 From: Iziram Date: Tue, 16 Jan 2024 16:15:48 +0100 Subject: [PATCH 7/7] =?UTF-8?q?Assiduites=20:=20am=C3=A9liorations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - - duplication const - + bouton justifier `ajout_assiduite_etud` - + auto actualiser `signal_assiduite_group` - - enum au lieu de str - + mettre css minitimeline.css - + resize timeline fix --- app/forms/assiduite/ajout_assiduite_etud.py | 2 + app/scodoc/sco_assiduites.py | 6 +- app/scodoc/sco_utils.py | 2 +- app/static/css/assiduites.css | 10 +- app/static/css/minitimeline.css | 212 ++++++++++++++++ app/static/js/assiduites.js | 40 ++- .../assiduites/pages/ajout_assiduite_etud.j2 | 7 + .../assiduites/pages/calendrier_assi_etud.j2 | 227 +----------------- .../pages/signal_assiduites_group.j2 | 3 - .../assiduites/widgets/minitimeline.j2 | 198 +++------------ app/templates/assiduites/widgets/timeline.j2 | 14 +- app/views/assiduites.py | 136 ++++------- 12 files changed, 361 insertions(+), 496 deletions(-) create mode 100644 app/static/css/minitimeline.css 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/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index b3136635..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) 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/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/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 f9f2887a..54265c6e 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -462,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, @@ -477,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) @@ -863,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"]) @@ -927,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, @@ -1071,6 +1072,7 @@ def signal_assiduites_group(): cssstyles=CSSSTYLES + [ "css/assiduites.css", + "css/minitimeline.css", ], ) @@ -1168,13 +1170,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: @@ -1218,6 +1226,7 @@ def visu_assiduites_group(): cssstyles=CSSSTYLES + [ "css/assiduites.css", + "css/minitimeline.css", ], ) @@ -1555,21 +1564,6 @@ 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": @@ -2318,7 +2312,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() @@ -2342,7 +2336,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(), @@ -2354,32 +2348,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 @@ -2392,8 +2360,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") @@ -2605,14 +2573,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] = []