From 0634dbd0aa1fab8499e4aeb69f61f53300ed22b0 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 15 Jan 2024 17:49:28 +0100 Subject: [PATCH 01/55] 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 02/55] =?UTF-8?q?Fix:=20bug=20synchro=20apo=20si=201=20seu?= =?UTF-8?q?l=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 03/55] 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 04/55] =?UTF-8?q?Assiduites=20:=20fin=20int=C3=A9gration?= =?UTF-8?q?=20pagination=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 e3fc13f2152326b3dd8ebcd6d356415a6727b219 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 16 Jan 2024 11:11:00 +0100 Subject: [PATCH 05/55] =?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 7ce57d28cb56aa1ca279724e66595b2c38404f4b Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 16 Jan 2024 12:36:20 +0100 Subject: [PATCH 06/55] 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 2c42a1547c339ea788a1861b95aa780d45fe0aec Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 16 Jan 2024 11:11:00 +0100 Subject: [PATCH 07/55] =?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 08/55] 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 09/55] =?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] = [] From 9104a8986e3f2fcf5ccf65d8e9d9113f345b708f Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 17 Jan 2024 21:58:45 +0100 Subject: [PATCH 10/55] EDT: raw event hidden in html. WIP: user edt. --- app/api/users.py | 66 ++++++++++++++++- app/scodoc/sco_edt_cal.py | 118 ++++++++++++++++++++---------- app/static/css/edt.css | 4 + app/templates/formsemestre/edt.j2 | 4 +- app/views/assiduites.py | 12 ++- tools/edt/edt_ens.py | 2 +- 6 files changed, 159 insertions(+), 47 deletions(-) diff --git a/app/api/users.py b/app/api/users.py index 0a9f7397..761ad0c5 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -7,7 +7,6 @@ """ ScoDoc 9 API : accès aux utilisateurs """ -import datetime from flask import g, request from flask_json import as_json @@ -15,13 +14,14 @@ from flask_login import current_user, login_required from app import db, log from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR -from app.scodoc.sco_utils import json_error from app.auth.models import User, Role, UserRole from app.auth.models import is_valid_password from app.decorators import scodoc, permission_required -from app.models import Departement +from app.models import Departement, ScoDocSiteConfig +from app.scodoc import sco_edt_cal from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import json_error from app.scodoc import sco_utils as scu @@ -441,3 +441,63 @@ def role_delete(role_name: str): db.session.delete(role) db.session.commit() return {"OK": True} + + +@bp.route("/user//edt") +@api_web_bp.route("/user//edt") +@login_required +@scodoc +@permission_required(Permission.ScoView) +@as_json +def user_edt(uid: int): + """L'emploi du temps de l'utilisateur. + Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur. + + show_modules_titles affiche le titre complet du module (défaut), sinon juste le code. + + Il faut la permission ScoView + (UsersView ou bien être connecté comme l'utilisateur demandé) + """ + if g.scodoc_dept is None: # route API non départementale + if not current_user.has_permission(Permission.UsersView): + return scu.json_error(403, "accès non autorisé") + user: User = db.session.get(User, uid) + if user is None: + return json_error(404, "user not found") + # Check permission + if current_user.id != user.id: + if g.scodoc_dept: + allowed_depts = current_user.get_depts_with_permission(Permission.UsersView) + if (None not in allowed_depts) and (user.dept not in allowed_depts): + return json_error(404, "user not found") + + show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False)) + + # Cherche ics + if not user.edt_id: + return json_error(404, "user not configured") + ics_filename = sco_edt_cal.get_ics_user_edt_filename(user.edt_id) + if not ics_filename: + return json_error(404, "no calendar for this user") + + _, calendar = sco_edt_cal.load_calendar(ics_filename) + + # TODO: + # - Construire mapping edt2modimpl: edt_id -> modimpl + # pour cela, considérer tous les formsemestres de la période de l'edt + # (soit on considère l'année scolaire du 1er event, ou celle courante, + # soit on cherche min, max des dates des events) + # - Modifier décodage des groupes dans convert_ics pour avoi run mapping + # de groupe par semestre (retrouvé grâce au modimpl associé à l'event) + + raise NotImplementedError() # TODO XXX WIP + + events_scodoc, _ = sco_edt_cal.convert_ics( + calendar, + edt2group=edt2group, + default_group=default_group, + edt2modimpl=edt2modimpl, + ) + edt_dict = sco_edt_cal.translate_calendar( + events_scodoc, group_ids, show_modules_titles=show_modules_titles + ) + return edt_dict diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index d4b8a1aa..b8cdf9ac 100644 --- a/app/scodoc/sco_edt_cal.py +++ b/app/scodoc/sco_edt_cal.py @@ -177,12 +177,26 @@ def formsemestre_edt_dict( TODO: spécifier intervalle de dates start et end """ t0 = time.time() - group_ids_set = set(group_ids) if group_ids else set() try: events_scodoc, _ = load_and_convert_ics(formsemestre) except ScoValueError as exc: return exc.args[0] - # Génération des événements pour le calendrier html + edt_dict = translate_calendar( + events_scodoc, group_ids, show_modules_titles=show_modules_titles + ) + log( + f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s" + ) + return edt_dict + + +def translate_calendar( + events_scodoc: list[dict], + group_ids: list[int] = None, + show_modules_titles=True, +) -> list[dict]: + """Génération des événements pour le calendrier html""" + group_ids_set = set(group_ids) if group_ids else set() promo_icon = f"""promotion""" abs_icon = f""" re.Pattern: def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[str]]: - """Chargement fichier ics, filtrage et extraction des identifiants. + """Chargement fichier ics. + Renvoie une liste d'évènements, et la liste des identifiants de groupes + trouvés (utilisée pour l'aide). + """ + # Chargement du calendier ics + _, calendar = formsemestre_load_calendar(formsemestre) + if not calendar: + return [], [] + # --- Correspondances id edt -> id scodoc pour groupes + edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) + default_group = formsemestre.get_default_group() + # --- Correspondances id edt -> id scodoc pour modimpls + edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) + return convert_ics( + calendar, + edt2group=edt2group, + default_group=default_group, + edt2modimpl=edt2modimpl, + ) + + +def convert_ics( + calendar: icalendar.cal.Calendar, + edt2group: dict[str, GroupDescr] = None, + default_group: GroupDescr = None, + edt2modimpl: dict[str, ModuleImpl] = None, +) -> tuple[list[dict], list[str]]: + """Filtrage et extraction des identifiants des évènements calendrier. + Renvoie une liste d'évènements, et la liste des identifiants de groupes trouvés (utilisée pour l'aide). @@ -310,10 +351,6 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s - False si extraction regexp non configuré - "tous" (promo) si pas de correspondance trouvée. """ - # Chargement du calendier ics - _, calendar = formsemestre_load_calendar(formsemestre) - if not calendar: - return [] # --- Paramètres d'extraction edt_ics_title_field = ScoDocSiteConfig.get("edt_ics_title_field") edt_ics_title_regexp = ScoDocSiteConfig.get("edt_ics_title_regexp") @@ -348,15 +385,13 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field") edt_ics_uid_pattern = get_ics_uid_pattern() - # --- Correspondances id edt -> id scodoc pour groupes, modules et enseignants - edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) + # --- Groupes group_colors = { group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1] for i, group_name in enumerate(edt2group) } edt_groups_ids = set() # les ids de groupes normalisés tels que dans l'ics - default_group = formsemestre.get_default_group() - edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) + edt2user: dict[str, User | None] = {} # construit au fur et à mesure (cache) # --- events = [e for e in calendar.walk() if e.name == "VEVENT"] @@ -371,29 +406,6 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s ) # title remplacé par le nom du module scodoc quand il est trouvé title = title_edt - # --- Group - if edt_ics_group_pattern: - edt_group = extract_event_edt_id( - event, edt_ics_group_field, edt_ics_group_pattern - ) - edt_groups_ids.add(edt_group) - # si pas de groupe dans l'event, ou si groupe non reconnu, - # prend toute la promo ("tous") - group: GroupDescr = ( - edt2group.get(edt_group, default_group) - if edt_group - else default_group - ) - group_bg_color = ( - group_colors.get(edt_group, _EVENT_DEFAULT_COLOR) - if group - else "lightgrey" - ) - else: - edt_group = "" - group = False - group_bg_color = _EVENT_DEFAULT_COLOR - # --- ModuleImpl if edt_ics_mod_pattern: edt_module = extract_event_edt_id( @@ -405,6 +417,34 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s else: modimpl = False edt_module = "" + # --- Group + if edt_ics_group_pattern: + edt_group = extract_event_edt_id( + event, edt_ics_group_field, edt_ics_group_pattern + ) + edt_groups_ids.add(edt_group) + # si pas de groupe dans l'event, ou si groupe non reconnu, + # prend toute la promo ("tous") + event_default_group = ( + default_group + if default_group + else (modimpl.formsemestre.get_default_group() if modimpl else None) + ) + group: GroupDescr = ( + edt2group.get(edt_group, event_default_group) + if edt_group + else event_default_group + ) + group_bg_color = ( + group_colors.get(edt_group, _EVENT_DEFAULT_COLOR) + if group + else "lightgrey" + ) + else: + edt_group = "" + group = False + group_bg_color = _EVENT_DEFAULT_COLOR + # --- Enseignants users: list[User] = [] if edt_ics_uid_pattern: @@ -446,6 +486,8 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s "jour": event.decoded("dtstart").date().isoformat(), "start": event.decoded("dtstart").isoformat(), "end": event.decoded("dtend").isoformat(), + "UID": event.decoded("UID").decode("utf-8"), + "raw": event.to_ical().decode("utf-8"), } ) return events_sco, sorted(edt_groups_ids) diff --git a/app/static/css/edt.css b/app/static/css/edt.css index cfbf9ef7..4644be99 100644 --- a/app/static/css/edt.css +++ b/app/static/css/edt.css @@ -144,3 +144,7 @@ span.ens-non-reconnu { .btn:active { outline: none; } + +.raw-event { + display: none; +} \ No newline at end of file diff --git a/app/templates/formsemestre/edt.j2 b/app/templates/formsemestre/edt.j2 index 7a72890a..b0a77f73 100644 --- a/app/templates/formsemestre/edt.j2 +++ b/app/templates/formsemestre/edt.j2 @@ -126,7 +126,7 @@ document.addEventListener('DOMContentLoaded', function() { time: function(event) { const date_start = new Date(event.start); const start = hm_formatter.format(date_start); - return `${start} ${event.title}`; + return `${start} ${event.title}
    ${event.raw}
    `; }, }, timezone: { @@ -249,10 +249,8 @@ document.addEventListener('DOMContentLoaded', function() { if ((iso_date_start > "{{ formsemestre.date_fin.isoformat() }}") || (iso_date_end < "{{ formsemestre.date_debut.isoformat() }}")) { cal_warning.style.display = 'inline-block'; - console.log("OUTSIDE"); } else { cal_warning.style.display = 'none'; - console.log("INSIDE"); } } // View menu diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 9b064d86..33f9277b 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -947,12 +947,13 @@ def choix_date() -> str: @permission_required(Permission.AbsChange) def signal_assiduites_group(): """ - signal_assiduites_group Saisie des assiduités des groupes pour le jour donnée + signal_assiduites_group Saisie des assiduités des groupes pour le jour donné Returns: str: l'html généré """ # Récupération des paramètres de l'url + # formsemestre_id est optionnel si modimpl est indiqué formsemestre_id: int = request.args.get("formsemestre_id", -1) moduleimpl_id: int = request.args.get("moduleimpl_id") date: str = request.args.get("jour", datetime.date.today().isoformat()) @@ -972,13 +973,20 @@ def signal_assiduites_group(): moduleimpl_id = int(moduleimpl_id) except (TypeError, ValueError): moduleimpl_id = None - + if moduleimpl_id >= 0 and moduleimpl_id is not None: + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) + else: + modimpl = None # Vérification du formsemestre_id try: formsemestre_id = int(formsemestre_id) except (TypeError, ValueError): formsemestre_id = None + if (formsemestre_id < 0 or formsemestre_id is None) and modimpl: + # si le module est spécifié mais pas le semestre: + formsemestre_id = modimpl.formsemestre_id + # Gestion des groupes groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids, diff --git a/tools/edt/edt_ens.py b/tools/edt/edt_ens.py index 8206cc03..63d0acf3 100644 --- a/tools/edt/edt_ens.py +++ b/tools/edt/edt_ens.py @@ -48,7 +48,7 @@ from app.scodoc.sco_exceptions import ScoValueError import sco_version -def _calendar_factory(): +def _calendar_factory() -> icalendar.Calendar: "Create a new calendar" cal = icalendar.Calendar() cal.add( From e2ca6732399fa4e5664b6341777be5616a9ad832 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 17 Jan 2024 23:51:45 +0100 Subject: [PATCH 11/55] Fix typo --- app/views/assiduites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 96e40021..d541eb9f 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -977,7 +977,7 @@ def signal_assiduites_group(): moduleimpl_id = int(moduleimpl_id) except (TypeError, ValueError): moduleimpl_id = None - if moduleimpl_id >= 0 and moduleimpl_id is not None: + if moduleimpl_id is not None and moduleimpl_id >= 0: modimpl = ModuleImpl.get_modimpl(moduleimpl_id) else: modimpl = None From 6d3f276cc0c237abff48880603d9bd5946d354fa Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 17 Jan 2024 23:52:14 +0100 Subject: [PATCH 12/55] Filigranne bulletins BUT: fix #844 --- app/but/bulletin_but.py | 6 ++---- app/but/bulletin_but_court.py | 4 ++++ app/but/bulletin_but_court_pdf.py | 4 +++- app/scodoc/sco_bulletins_pdf.py | 29 +++++++++++++++++++++++++---- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index c8cf061d..5ec30c5e 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -499,10 +499,8 @@ class BulletinBUT: d["etud"]["etat_civil"] = etud.etat_civil d.update(self.res.sem) etud_etat = self.res.get_etud_etat(etud.id) - d["filigranne"] = sco_bulletins_pdf.get_filigranne( - etud_etat, - self.prefs, - decision_sem=d["semestre"].get("decision"), + d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc( + etud_etat, self.prefs, etud.id, res=self.res ) if etud_etat == scu.DEMISSION: d["demission"] = "(Démission)" diff --git a/app/but/bulletin_but_court.py b/app/but/bulletin_but_court.py index ea52d0f2..c8d25828 100644 --- a/app/but/bulletin_but_court.py +++ b/app/but/bulletin_but_court.py @@ -35,6 +35,7 @@ from app.decorators import ( permission_required, ) from app.models import FormSemestre, FormSemestreInscription, Identite +from app.scodoc import sco_bulletins_pdf from app.scodoc.codes_cursus import UE_STANDARD from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError from app.scodoc.sco_logos import find_logo @@ -104,8 +105,10 @@ def _build_bulletin_but_infos( bulletins_sem = BulletinBUT(formsemestre) if fmt == "pdf": bul: dict = bulletins_sem.bulletin_etud_complet(etud) + filigranne = bul["filigranne"] else: # la même chose avec un peu moins d'infos bul: dict = bulletins_sem.bulletin_etud(etud, force_publishing=True) + filigranne = "" decision_ues = ( {x["acronyme"]: x for x in bul["semestre"]["decision_ue"]} if "semestre" in bul and "decision_ue" in bul["semestre"] @@ -131,6 +134,7 @@ def _build_bulletin_but_infos( "decision_ues": decision_ues, "ects_total": ects_total, "etud": etud, + "filigranne": filigranne, "formsemestre": formsemestre, "logo": logo, "prefs": bulletins_sem.prefs, diff --git a/app/but/bulletin_but_court_pdf.py b/app/but/bulletin_but_court_pdf.py index bef62ae4..58528045 100644 --- a/app/but/bulletin_but_court_pdf.py +++ b/app/but/bulletin_but_court_pdf.py @@ -48,6 +48,7 @@ def make_bulletin_but_court_pdf( ects_total: float = 0.0, etud: Identite = None, formsemestre: FormSemestre = None, + filigranne="" logo: Logo = None, prefs: SemPreferences = None, title: str = "", @@ -86,6 +87,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard): decision_ues: dict = None, ects_total: float = 0.0, etud: Identite = None, + filigranne="", formsemestre: FormSemestre = None, logo: Logo = None, prefs: SemPreferences = None, @@ -95,7 +97,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard): ] = None, ues_acronyms: list[str] = None, ): - super().__init__(bul, authuser=current_user) + super().__init__(bul, authuser=current_user, filigranne=filigranne) self.bul = bul self.cursus = cursus self.decision_ues = decision_ues diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py index f61ba8ac..adac2988 100644 --- a/app/scodoc/sco_bulletins_pdf.py +++ b/app/scodoc/sco_bulletins_pdf.py @@ -60,6 +60,7 @@ import traceback from flask import g, request from app import log, ScoValueError +from app.comp.res_but import ResultatsSemestreBUT from app.models import FormSemestre, Identite from app.scodoc import sco_cache from app.scodoc import codes_cursus @@ -318,14 +319,34 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"): return pdfdoc, filename -def get_filigranne(etud_etat: str, prefs, decision_sem=None) -> str: - """Texte à placer en "filigranne" sur le bulletin pdf""" +def get_filigranne( + etud_etat: str, prefs, decision_sem: str | None | bool = None +) -> str: + """Texte à placer en "filigranne" sur le bulletin pdf. + etud_etat : etat de l'inscription (I ou D) + decision_sem = code jury ou vide + """ if etud_etat == scu.DEMISSION: return "Démission" - elif etud_etat == codes_cursus.DEF: + if etud_etat == codes_cursus.DEF: return "Défaillant" - elif (prefs["bul_show_temporary"] and not decision_sem) or prefs[ + if (prefs["bul_show_temporary"] and not decision_sem) or prefs[ "bul_show_temporary_forced" ]: return prefs["bul_temporary_txt"] return "" + + +def get_filigranne_apc( + etud_etat: str, prefs, etudid: int, res: ResultatsSemestreBUT +) -> str: + """Texte à placer en "filigranne" sur le bulletin pdf. + Version optimisée pour BUT + """ + if prefs["bul_show_temporary_forced"]: + return get_filigranne(etud_etat, prefs) + if prefs["bul_show_temporary"]: + # requete les décisions de jury + decision_sem = res.etud_has_decision(etudid) + return get_filigranne(etud_etat, prefs, decision_sem=decision_sem) + return get_filigranne(etud_etat, prefs) From 21eeff90aa52fa93ffeb2e725998e32155df4789 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 18 Jan 2024 00:27:17 +0100 Subject: [PATCH 13/55] WIP: corrections pour passer tests unitaires. api/test_api_justificatifs.py ne passe pas (user_id) --- app/api/justificatifs.py | 3 +- app/api/users.py | 100 +++++++++++++++++++-------------------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index a22ca39a..611785ef 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -15,7 +15,7 @@ from werkzeug.exceptions import NotFound import app.scodoc.sco_assiduites as scass import app.scodoc.sco_utils as scu -from app import db +from app import db, set_sco_dept from app.api import api_bp as bp from app.api import api_web_bp from app.api import get_model_api_object, tools @@ -294,6 +294,7 @@ def justif_create(etudid: int = None, nip=None, ine=None): 404, message="étudiant inconnu", ) + set_sco_dept(etud.departement.acronym) # Récupération des justificatifs à créer create_list: list[object] = request.get_json(force=True) diff --git a/app/api/users.py b/app/api/users.py index 761ad0c5..c038ffb8 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -443,61 +443,61 @@ def role_delete(role_name: str): return {"OK": True} -@bp.route("/user//edt") -@api_web_bp.route("/user//edt") -@login_required -@scodoc -@permission_required(Permission.ScoView) -@as_json -def user_edt(uid: int): - """L'emploi du temps de l'utilisateur. - Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur. +# @bp.route("/user//edt") +# @api_web_bp.route("/user//edt") +# @login_required +# @scodoc +# @permission_required(Permission.ScoView) +# @as_json +# def user_edt(uid: int): +# """L'emploi du temps de l'utilisateur. +# Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur. - show_modules_titles affiche le titre complet du module (défaut), sinon juste le code. +# show_modules_titles affiche le titre complet du module (défaut), sinon juste le code. - Il faut la permission ScoView + (UsersView ou bien être connecté comme l'utilisateur demandé) - """ - if g.scodoc_dept is None: # route API non départementale - if not current_user.has_permission(Permission.UsersView): - return scu.json_error(403, "accès non autorisé") - user: User = db.session.get(User, uid) - if user is None: - return json_error(404, "user not found") - # Check permission - if current_user.id != user.id: - if g.scodoc_dept: - allowed_depts = current_user.get_depts_with_permission(Permission.UsersView) - if (None not in allowed_depts) and (user.dept not in allowed_depts): - return json_error(404, "user not found") +# Il faut la permission ScoView + (UsersView ou bien être connecté comme l'utilisateur demandé) +# """ +# if g.scodoc_dept is None: # route API non départementale +# if not current_user.has_permission(Permission.UsersView): +# return scu.json_error(403, "accès non autorisé") +# user: User = db.session.get(User, uid) +# if user is None: +# return json_error(404, "user not found") +# # Check permission +# if current_user.id != user.id: +# if g.scodoc_dept: +# allowed_depts = current_user.get_depts_with_permission(Permission.UsersView) +# if (None not in allowed_depts) and (user.dept not in allowed_depts): +# return json_error(404, "user not found") - show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False)) +# show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False)) - # Cherche ics - if not user.edt_id: - return json_error(404, "user not configured") - ics_filename = sco_edt_cal.get_ics_user_edt_filename(user.edt_id) - if not ics_filename: - return json_error(404, "no calendar for this user") +# # Cherche ics +# if not user.edt_id: +# return json_error(404, "user not configured") +# ics_filename = sco_edt_cal.get_ics_user_edt_filename(user.edt_id) +# if not ics_filename: +# return json_error(404, "no calendar for this user") - _, calendar = sco_edt_cal.load_calendar(ics_filename) +# _, calendar = sco_edt_cal.load_calendar(ics_filename) - # TODO: - # - Construire mapping edt2modimpl: edt_id -> modimpl - # pour cela, considérer tous les formsemestres de la période de l'edt - # (soit on considère l'année scolaire du 1er event, ou celle courante, - # soit on cherche min, max des dates des events) - # - Modifier décodage des groupes dans convert_ics pour avoi run mapping - # de groupe par semestre (retrouvé grâce au modimpl associé à l'event) +# # TODO: +# # - Construire mapping edt2modimpl: edt_id -> modimpl +# # pour cela, considérer tous les formsemestres de la période de l'edt +# # (soit on considère l'année scolaire du 1er event, ou celle courante, +# # soit on cherche min, max des dates des events) +# # - Modifier décodage des groupes dans convert_ics pour avoi run mapping +# # de groupe par semestre (retrouvé grâce au modimpl associé à l'event) - raise NotImplementedError() # TODO XXX WIP +# raise NotImplementedError() # TODO XXX WIP - events_scodoc, _ = sco_edt_cal.convert_ics( - calendar, - edt2group=edt2group, - default_group=default_group, - edt2modimpl=edt2modimpl, - ) - edt_dict = sco_edt_cal.translate_calendar( - events_scodoc, group_ids, show_modules_titles=show_modules_titles - ) - return edt_dict +# events_scodoc, _ = sco_edt_cal.convert_ics( +# calendar, +# edt2group=edt2group, +# default_group=default_group, +# edt2modimpl=edt2modimpl, +# ) +# edt_dict = sco_edt_cal.translate_calendar( +# events_scodoc, group_ids, show_modules_titles=show_modules_titles +# ) +# return edt_dict From 4b304c559b5a3a96d21bf0a47e684d078919b518 Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 18 Jan 2024 09:04:25 +0100 Subject: [PATCH 14/55] Assiduites : fix user_id justi + fix scodoc_dept dans cache --- app/models/assiduites.py | 20 ++++++++++++-------- app/scodoc/sco_assiduites.py | 6 +++++- app/tables/liste_assiduites.py | 8 ++++++-- tests/api/test_api_justificatifs.py | 4 +++- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 46584d9a..c7cf8fa3 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -434,6 +434,14 @@ class Justificatif(ScoDocModel): etudiant = db.relationship( "Identite", back_populates="justificatifs", lazy="joined" ) + # En revanche, user est rarement accédé: + user = db.relationship( + "User", + backref=db.backref( + "justificatifs", lazy="select", order_by="Justificatif.entry_date" + ), + lazy="select", + ) external_data = db.Column(db.JSON, nullable=True) @@ -449,16 +457,10 @@ class Justificatif(ScoDocModel): """transformation de l'objet en dictionnaire sérialisable""" etat = self.etat - username = self.user_id + user: User = self.user if self.user_id is not None else None if format_api: etat = EtatJustificatif.inverse().get(self.etat).name - if self.user_id is not None: - user: User = db.session.get(User, self.user_id) - if user is None: - username = "Non renseigné" - else: - username = user.get_prenomnom() data = { "justif_id": self.justif_id, @@ -470,7 +472,9 @@ class Justificatif(ScoDocModel): "raison": self.raison, "fichier": self.fichier, "entry_date": self.entry_date, - "user_id": username, + "user_id": None if user is None else user.id, # l'uid + "user_name": None if user is None else user.user_name, # le login + "user_nom_complet": None if user is None else user.get_nomcomplet(), "external_data": self.external_data, } return data diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 7269654d..70121a17 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -6,7 +6,7 @@ from pytz import UTC from flask_sqlalchemy.query import Query -from app import log, db +from app import log, db, set_sco_dept import app.scodoc.sco_utils as scu from app.models.assiduites import Assiduite, Justificatif, compute_assiduites_justified from app.models.etudiants import Identite @@ -756,6 +756,10 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None): invalidate_assiduites_etud_date(etudid, date_debut) invalidate_assiduites_etud_date(etudid, date_fin) + # mettre à jour le scodoc_dept en fonction de l'étudiant + etud = Identite.query.filter_by(etudid=etudid).first_or_404() + set_sco_dept(etud.departement.acronym) + # Invalide les caches des tableaux de l'étudiant sco_cache.RequeteTableauAssiduiteCache.delete_pattern( pattern=f"tableau-etud-{etudid}:*" diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index 8ad7992e..f288faed 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -529,9 +529,13 @@ class RowAssiJusti(tb.Row): ) html.append(f'') # utiliser url_for - # Justifier (si type Assiduité et est_just faux) + # Justifier (si type Assiduité, etat != Présent et est_just faux) - if self.ligne["type"] == "assiduite" and not self.ligne["est_just"]: + if ( + self.ligne["type"] == "assiduite" + and self.ligne["etat"] != EtatAssiduite.PRESENT + and not self.ligne["est_just"] + ): url = url_for( "assiduites.tableau_assiduite_actions", type=self.ligne["type"], diff --git a/tests/api/test_api_justificatifs.py b/tests/api/test_api_justificatifs.py index 53e1b370..aedebaee 100644 --- a/tests/api/test_api_justificatifs.py +++ b/tests/api/test_api_justificatifs.py @@ -32,7 +32,9 @@ JUSTIFICATIFS_FIELDS = { "raison": str, "entry_date": str, "fichier": str, - "user_id": int, + "user_id": int | None, + "user_name": int | None, + "user_nom_complet": int | None, "external_data": dict, } From 78d97d2c2dbe625416985920976bb596997c8123 Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 18 Jan 2024 09:36:38 +0100 Subject: [PATCH 15/55] Assiduites : fix couleur etat_abs_date fixes #830 --- app/views/assiduites.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/views/assiduites.py b/app/views/assiduites.py index d541eb9f..ed037593 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -1276,11 +1276,13 @@ class RowEtudWithAssi(RowEtud): table: TableEtud, etud: Identite, etat_assiduite: str, + est_just: bool, *args, **kwargs, ): super().__init__(table, etud, *args, **kwargs) self.etat_assiduite = etat_assiduite + self.est_just = est_just # remplace lien vers fiche par lien vers calendrier self.target_url = url_for( "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id @@ -1298,6 +1300,9 @@ class RowEtudWithAssi(RowEtud): ) self.classes += ["row-assiduite", self.etat_assiduite.lower()] + if self.est_just: + self.classes += ["justifiee"] + @bp.route("/etat_abs_date") @scodoc @@ -1345,9 +1350,7 @@ def etat_abs_date(): Assiduite.etudid.in_([etud.id for etud in etuds]) ) # Filtrage des assiduités en fonction des dates données - assiduites = scass.filter_by_date( - assiduites, Assiduite, date_debut, date_fin, False - ) + assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin) # Génération table table = TableEtud(row_class=RowEtudWithAssi) @@ -1357,7 +1360,7 @@ def etat_abs_date(): etat = "" if assi is not None and assi.etat != scu.EtatAssiduite.PRESENT: etat = scu.EtatAssiduite.inverse().get(assi.etat).name - row = table.row_class(table, etud, etat) + row = table.row_class(table, etud, etat, assi.est_just) row.add_etud_cols() table.add_row(row) From 44de81857a1e4abd01b8fa1020070e6a96878f61 Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 18 Jan 2024 15:09:39 +0100 Subject: [PATCH 16/55] Assiduites : fix typo type test_api_justif --- tests/api/test_api_justificatifs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api/test_api_justificatifs.py b/tests/api/test_api_justificatifs.py index aedebaee..5e73d2c7 100644 --- a/tests/api/test_api_justificatifs.py +++ b/tests/api/test_api_justificatifs.py @@ -33,8 +33,8 @@ JUSTIFICATIFS_FIELDS = { "entry_date": str, "fichier": str, "user_id": int | None, - "user_name": int | None, - "user_nom_complet": int | None, + "user_name": str | None, + "user_nom_complet": str | None, "external_data": dict, } From 7659bcb488e1f90fcc86f82a0aebf431c69c135e Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 18 Jan 2024 17:05:43 +0100 Subject: [PATCH 17/55] Assiduites : WIP todos --- app/forms/main/config_assiduites.py | 81 ++++------ app/scodoc/sco_archives_justificatifs.py | 24 ++- app/scodoc/sco_assiduites.py | 29 ++-- app/scodoc/sco_utils.py | 7 +- app/static/js/assiduites.js | 36 ++--- app/tables/visu_assiduites.py | 51 ++++--- app/templates/assiduites/pages/bilan_etud.j2 | 143 ++++++++---------- .../assiduites/pages/config_assiduites.j2 | 18 ++- app/templates/assiduites/widgets/conflict.j2 | 17 ++- app/templates/assiduites/widgets/tableau.j2 | 49 ++++++ app/views/absences.py | 3 +- app/views/assiduites.py | 4 +- app/views/scodoc.py | 10 +- 13 files changed, 257 insertions(+), 215 deletions(-) diff --git a/app/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py index eec5623e..2ab33165 100644 --- a/app/forms/main/config_assiduites.py +++ b/app/forms/main/config_assiduites.py @@ -34,52 +34,11 @@ import re from flask_wtf import FlaskForm from wtforms import DecimalField, SubmitField, ValidationError from wtforms.fields.simple import StringField -from wtforms.validators import Optional +from wtforms.validators import Optional, Length from wtforms.widgets import TimeInput -class TimeField(StringField): - """HTML5 time input. - tiré de : https://gist.github.com/tachyondecay/6016d32f65a996d0d94f - """ - - widget = TimeInput() - - def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs): - super(TimeField, self).__init__(label, validators, **kwargs) - self.fmt = fmt - self.data = None - - def _value(self): - if self.raw_data: - return " ".join(self.raw_data) - if self.data and isinstance(self.data, str): - self.data = datetime.time(*map(int, self.data.split(":"))) - return self.data and self.data.strftime(self.fmt) or "" - - def process_formdata(self, valuelist): - if valuelist: - time_str = " ".join(valuelist) - try: - components = time_str.split(":") - hour = 0 - minutes = 0 - seconds = 0 - if len(components) in range(2, 4): - hour = int(components[0]) - minutes = int(components[1]) - - if len(components) == 3: - seconds = int(components[2]) - else: - raise ValueError - self.data = datetime.time(hour, minutes, seconds) - except ValueError as exc: - self.data = None - raise ValueError(self.gettext("Not a valid time string")) from exc - - def check_tick_time(form, field): """Le tick_time doit être entre 0 et 60 minutes""" if field.data < 1 or field.data > 59: @@ -118,14 +77,36 @@ def check_ics_regexp(form, field): class ConfigAssiduitesForm(FlaskForm): "Formulaire paramétrage Module Assiduité" - - assi_morning_time = TimeField( - "Début de la journée" - ) # TODO utiliser TextField + timepicker voir AjoutAssiOrJustForm - assi_lunch_time = TimeField( - "Heure de midi (date pivot entre matin et après-midi)" - ) # TODO - assi_afternoon_time = TimeField("Fin de la journée") # TODO + assi_morning_time = StringField( + "Début de la journée", + default="", + validators=[Length(max=5)], + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_morning_time", + }, + ) + assi_lunch_time = StringField( + "Heure de midi (date pivot entre matin et après-midi)", + default="", + validators=[Length(max=5)], + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_lunch_time", + }, + ) + assi_afternoon_time = StringField( + "Fin de la journée", + validators=[Length(max=5)], + default="", + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_afternoon_time", + }, + ) assi_tick_time = DecimalField( "Granularité de la timeline (temps en minutes)", diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py index b0fb1d3e..abdb9fff 100644 --- a/app/scodoc/sco_archives_justificatifs.py +++ b/app/scodoc/sco_archives_justificatifs.py @@ -17,7 +17,15 @@ from app import log class Trace: """gestionnaire de la trace des fichiers justificatifs - XXX TODO à documenter: rôle et format des fichier strace + + Role des fichiers traces : + - Sauvegarder la date de dépot du fichier + - Sauvegarder la date de suppression du fichier (dans le cas de plusieurs fichiers pour un même justif) + - Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier (=> permet de montrer les fichiers qu'aux personnes qui l'on déposé / qui ont le rôle AssiJustifView) + + _trace.csv : + nom_fichier_srv,datetime_depot,datetime_suppr,user_id + """ def __init__(self, path: str) -> None: @@ -39,7 +47,7 @@ class Trace: continue entry_date: datetime = is_iso_formated(csv[1], True) delete_date: datetime = is_iso_formated(csv[2], True) - user_id = csv[3] + user_id = csv[3].strip() self.content[fname] = [entry_date, delete_date, user_id] if os.path.isfile(self.path): @@ -84,7 +92,14 @@ class Trace: self, fnames: list[str] = None ) -> dict[str, list[datetime, datetime, str]]: """Récupère la trace pour les noms de fichiers. - si aucun nom n'est donné, récupère tous les fichiers""" + si aucun nom n'est donné, récupère tous les fichiers + + retour : + { + "nom_fichier_srv": [datetime_depot, datetime_suppr/None, user_id], + ... + } + """ if fnames is None: return self.content @@ -215,8 +230,7 @@ class JustificatifArchiver(BaseArchiver): filenames = self.list_archive(archive_id, dept_id=etud.dept_id) trace: Trace = Trace(archive_id) traced = trace.get_trace(filenames) - - return [(key, value[2]) for key, value in traced.items()] + return [(key, value[2]) for key, value in traced.items() if value is not None] def get_justificatif_file(self, archive_name: str, etud: Identite, filename: str): """ diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 70121a17..1421ff28 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -450,8 +450,6 @@ def filter_by_date( if date_fin is None: date_fin = datetime.max - date_deb = scu.localize_datetime(date_deb) # TODO A modifier (timezone ?) - date_fin = scu.localize_datetime(date_fin) if not strict: return collection.filter( collection_cls.date_debut <= date_fin, collection_cls.date_fin >= date_deb @@ -558,15 +556,19 @@ def get_all_justified( return after -def create_absence( +def create_absence_billet( date_debut: datetime, date_fin: datetime, etudid: int, description: str = None, est_just: bool = False, ) -> int: - """TODO: doc, dire quand l'utiliser""" - # TODO + """ + Permet de rapidement créer une absence. + **UTILISÉ UNIQUEMENT POUR LES BILLETS** + Ne pas utiliser autre par. + TALK: Vérifier si nécessaire + """ etud: Identite = Identite.query.filter_by(etudid=etudid).first_or_404() assiduite_unique: Assiduite = Assiduite.create_assiduite( etud=etud, @@ -648,8 +650,7 @@ def get_assiduites_count_in_interval( """ date_debut_iso = date_debut_iso or date_debut.isoformat() date_fin_iso = date_fin_iso or date_fin.isoformat() - # TODO Question: pourquoi ne pas cacher toutes les métriques, si l'API les veut toutes ? - key = f"{etudid}_{date_debut_iso}_{date_fin_iso}{metrique}_assiduites" + key = f"{etudid}_{date_debut_iso}_{date_fin_iso}_assiduites" r = sco_cache.AbsSemEtudCache.get(key) if not r or moduleimpl_id is not None: @@ -666,24 +667,24 @@ def get_assiduites_count_in_interval( calculator: CountCalculator = CountCalculator() calculator.compute_assiduites(assiduites) calcul: dict = calculator.to_dict(only_total=False) - nb_abs: dict = calcul["absent"][metrique] - nb_abs_just: dict = calcul["absent_just"][metrique] - r = (nb_abs, nb_abs_just) + r = calcul if moduleimpl_id is None: ans = sco_cache.AbsSemEtudCache.set(key, r) if not ans: log("warning: get_assiduites_count failed to cache") - return r + + nb_abs: dict = r["absent"][metrique] + nb_abs_just: dict = r["absent_just"][metrique] + return (nb_abs, nb_abs_just) def invalidate_assiduites_count(etudid: int, sem: dict): """Invalidate (clear) cached counts""" date_debut = sem["date_debut_iso"] date_fin = sem["date_fin_iso"] - for met in scu.AssiduitesMetrics.TAG: - key = str(etudid) + "_" + date_debut + "_" + date_fin + f"{met}_assiduites" - sco_cache.AbsSemEtudCache.delete(key) + key = str(etudid) + "_" + date_debut + "_" + date_fin + "_assiduites" + sco_cache.AbsSemEtudCache.delete(key) # Non utilisé diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 4ba283e1..a6d5d8b9 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -299,8 +299,11 @@ def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or No def localize_datetime(date: datetime.datetime or str) -> datetime.datetime: - """Ajoute un timecode UTC à la date donnée. - XXX semble faire autre chose... TODO fix this comment + """Transforme une date sans offset en une date avec offset + Tente de mettre l'offset de la timezone du serveur (ex : UTC+1) + Si erreur, mettra l'offset UTC + + TODO : vérifier puis supprimer l'auto conversion str-> datetime """ if isinstance(date, str): date = is_iso_formated(date, convert=True) diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index b1522e7d..bd22ecb2 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1282,19 +1282,14 @@ function getAllAssiduitesFromEtud( .replace("°", courant ? "&courant" : "") : "" }`; - //TODO Utiliser async_get au lieu de jquery - $.ajax({ - async: true, - type: "GET", - url: url_api, - success: (data, status) => { - if (status === "success") { - assiduites[etudid] = data; - action(data); - } + async_get( + url_api, + (data) => { + assiduites[etudid] = data; + action(data); }, - error: () => {}, - }); + (_) => {} + ); } /** @@ -1864,18 +1859,13 @@ function getAllJustificatifsFromEtud( order ? "/query?order°".replace("°", courant ? "&courant" : "") : "" }`; - //TODO Utiliser async_get au lieu de jquery - $.ajax({ - async: true, - type: "GET", - url: url_api, - success: (data, status) => { - if (status === "success") { - action(data); - } + async_get( + url_api, + (data) => { + action(data); }, - error: () => {}, - }); + () => {} + ); } function deleteJustificatif(justif_id) { diff --git a/app/tables/visu_assiduites.py b/app/tables/visu_assiduites.py index 0b7ee3fc..ed37c287 100644 --- a/app/tables/visu_assiduites.py +++ b/app/tables/visu_assiduites.py @@ -129,41 +129,44 @@ class RowAssi(tb.Row): ) def _get_etud_stats(self, etud: Identite) -> dict[str, list[str, float, float]]: - # XXX TODO @iziram commentaire sur la fonction et la var. retour + """ + Renvoie le comptage (dans la métrique du département) des différents états d'assiduité d'un étudiant + + Returns : + { + "" : [, , ] + } + + """ + + # Préparation du retour retour: dict[str, tuple[str, float, float]] = { "absent": ["Absences", 0.0, 0.0], "retard": ["Retards", 0.0, 0.0], "present": ["Présences", 0.0, 0.0], } + # Récupération de la métrique du département assi_metric = scu.translate_assiduites_metric( sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id), ) + compte_etat: dict[str, dict] = scass.get_assiduites_stats( + assiduites=etud.assiduites, + metric=assi_metric, + filtered={ + "date_debut": self.dates[0], + "date_fin": self.dates[1], + "etat": "absent,present,retard", # pour tout compter d'un coup + "split": 1, # afin d'avoir la division des stats en état, etatjust, etatnonjust + }, + ) + + # Pour chaque état on mets à jour les valeurs de retour for etat, valeur in retour.items(): - compte_etat = scass.get_assiduites_stats( - assiduites=etud.assiduites, - metric=assi_metric, - filtered={ - "date_debut": self.dates[0], - "date_fin": self.dates[1], - "etat": etat, - }, - ) - - compte_etat_just = scass.get_assiduites_stats( - assiduites=etud.assiduites, - metric=assi_metric, - filtered={ - "date_debut": self.dates[0], - "date_fin": self.dates[1], - "etat": etat, - "est_just": True, - }, - ) - - valeur[1] = compte_etat[assi_metric] - valeur[2] = compte_etat_just[assi_metric] + valeur[1] = compte_etat[etat][assi_metric] + if etat != "present": + valeur[2] = compte_etat[etat]["justifie"][assi_metric] return retour diff --git a/app/templates/assiduites/pages/bilan_etud.j2 b/app/templates/assiduites/pages/bilan_etud.j2 index 02ba9591..7c1f1c7e 100644 --- a/app/templates/assiduites/pages/bilan_etud.j2 +++ b/app/templates/assiduites/pages/bilan_etud.j2 @@ -28,7 +28,7 @@

    Absences et retards non justifiés

    - {# XXX XXX XXX #} + {# TODO Utiliser python tableau plutot que js tableau #}
    Attention, cette page utilise des couleurs et conventions différentes de celles des autres pages ScoDoc: elle sera prochainement modifée, merci de votre patience.
    @@ -111,89 +111,76 @@ } - function getAssiduitesCount(dateDeb, dateFin, query) { - const url_api = getUrl() + `/api/assiduites/${etudid}/count/query?date_debut=${dateDeb}&date_fin=${dateFin}&${query}`; + function getAssiduitesCount(dateDeb, dateFin, action) { + const url_api = getUrl() + `/api/assiduites/${etudid}/count/query?date_debut=${dateDeb}&date_fin=${dateFin}&etat=absent,retard,present&split`; //Utiliser async_get au lieu de Jquery - return $.ajax({ - async: true, - type: "GET", - url: url_api, - success: (data, status) => { - if (status === "success") { - } + async_get( + url_api, + action, + ()=>{}, + ); + } + + function showStats(data){ + const counter = { + "present": { + "total": data["present"], }, - error: () => { }, + "retard": { + "total": data["retard"], + "justi": data["retard"]["justifie"], + }, + "absent": { + "total": data["absent"], + "justi": data["absent"]["justifie"], + } + } + + const values = document.querySelector('.stats-values'); + values.innerHTML = ""; + + Object.keys(counter).forEach((key) => { + const item = document.createElement('div'); + item.classList.add('stats-values-item'); + + const div = document.createElement('div'); + div.classList.add('stats-values-part'); + + const withJusti = (key, metric) => { + if (key == "present") return ""; + return ` dont ${counter[key].justi[metric]} justifiées` + } + + const heure = document.createElement('span'); + heure.textContent = `${counter[key].total.heure} heure(s)${withJusti(key, "heure")}`; + + const demi = document.createElement('span'); + demi.textContent = `${counter[key].total.demi} demi-journée(s)${withJusti(key, "demi")}`; + + const jour = document.createElement('span'); + jour.textContent = `${counter[key].total.journee} journée(s)${withJusti(key, "journee")}`; + + div.append(jour, demi, heure); + + const title = document.createElement('h5'); + title.textContent = key.capitalize(); + + item.append(title, div) + + values.appendChild(item); }); + + const nbAbs = data["absent"]["non_justifie"][assi_metric]; + if (nbAbs > assi_seuil) { + document.querySelector('.alerte').classList.remove('invisible'); + document.querySelector('.alerte p').textContent = `Attention, cet étudiant a trop d'absences ${nbAbs} / ${assi_seuil} (${metriques[assi_metric]})` + } else { + document.querySelector('.alerte').classList.add('invisible'); + } } function countAssiduites(dateDeb, dateFin) { - //TODO Utiliser Fetch when plutot que jquery - $.when( - getAssiduitesCount(dateDeb, dateFin, `etat=present`), - getAssiduitesCount(dateDeb, dateFin, `etat=retard`), - getAssiduitesCount(dateDeb, dateFin, `etat=retard&est_just=v`), - getAssiduitesCount(dateDeb, dateFin, `etat=absent`), - getAssiduitesCount(dateDeb, dateFin, `etat=absent&est_just=v`), - ).then( - (pt, rt, rj, at, aj) => { - const counter = { - "present": { - "total": pt[0], - }, - "retard": { - "total": rt[0], - "justi": rj[0], - }, - "absent": { - "total": at[0], - "justi": aj[0], - } - } - - const values = document.querySelector('.stats-values'); - values.innerHTML = ""; - - Object.keys(counter).forEach((key) => { - const item = document.createElement('div'); - item.classList.add('stats-values-item'); - - const div = document.createElement('div'); - div.classList.add('stats-values-part'); - - const withJusti = (key, metric) => { - if (key == "present") return ""; - return ` dont ${counter[key].justi[metric]} justifiées` - } - - const heure = document.createElement('span'); - heure.textContent = `${counter[key].total.heure} heure(s)${withJusti(key, "heure")}`; - - const demi = document.createElement('span'); - demi.textContent = `${counter[key].total.demi} demi-journée(s)${withJusti(key, "demi")}`; - - const jour = document.createElement('span'); - jour.textContent = `${counter[key].total.journee} journée(s)${withJusti(key, "journee")}`; - - div.append(jour, demi, heure); - - const title = document.createElement('h5'); - title.textContent = key.capitalize(); - - item.append(title, div) - - values.appendChild(item); - }); - - const nbAbs = counter.absent.total[assi_metric] - counter.absent.justi[assi_metric]; - if (nbAbs > assi_seuil) { - document.querySelector('.alerte').classList.remove('invisible'); - document.querySelector('.alerte p').textContent = `Attention, cet étudiant a trop d'absences ${nbAbs} / ${assi_seuil} (${metriques[assi_metric]})` - } else { - document.querySelector('.alerte').classList.add('invisible'); - } - } - ); - + getAssiduitesCount(dateDeb, dateFin, showStats); } function removeAllAssiduites() { diff --git a/app/templates/assiduites/pages/config_assiduites.j2 b/app/templates/assiduites/pages/config_assiduites.j2 index 1b71738e..b69f5a2e 100644 --- a/app/templates/assiduites/pages/config_assiduites.j2 +++ b/app/templates/assiduites/pages/config_assiduites.j2 @@ -3,6 +3,7 @@ {% block styles %} {{super()}} + \ No newline at end of file +{% endblock app_content %} \ No newline at end of file diff --git a/app/templates/assiduites/pages/bilan_etud.j2 b/app/templates/assiduites/pages/bilan_etud.j2 index 7c1f1c7e..6f9a320f 100644 --- a/app/templates/assiduites/pages/bilan_etud.j2 +++ b/app/templates/assiduites/pages/bilan_etud.j2 @@ -25,27 +25,7 @@
    - -

    Absences et retards non justifiés

    - - {# TODO Utiliser python tableau plutot que js tableau #} -
    Attention, cette page utilise des couleurs et conventions différentes - de celles des autres pages ScoDoc: elle sera prochainement modifée, merci de votre patience. -
    - - - - - - {% include "assiduites/widgets/tableau_assi.j2" %} - -

    Justificatifs en attente (ou modifiés)

    - - - - - {% include "assiduites/widgets/tableau_justi.j2" %} - + {{tableau | safe }}
    @@ -60,29 +40,6 @@ département)

    Les statistiques sont calculées entre les deux dates sélectionnées. Après modification des dates, appuyer sur le bouton "Actualiser"

    -

    Gestion des justificatifs

    -

    - Faites - clic droit sur une ligne du tableau pour afficher le menu - contextuel : -

    -
      -
    • Détails : affiche les détails du justificatif sélectionné
    • -
    • Éditer : modifie le justificatif (dates, état, ajouter/supprimer fichier, etc.)
    • -
    • Supprimer : supprime le justificatif (action irréversible)
    • -
    - -

    Gestion de l'assiduité

    -

    - Faites - clic droit sur une ligne du tableau pour afficher le menu - contextuel : -

    -
      -
    • Détails : affiche les détails de l'élément sélectionnée
    • -
    • Editer : modifie l'élément (module, état)
    • -
    • Supprimer : supprime l'élément (action irréversible)
    • -
    @@ -275,48 +232,8 @@ window.addEventListener('load', () => { - filterAssiduites = { - "columns": [ - "entry_date", - "date_debut", - "date_fin", - "etat", - "moduleimpl_id", - "est_just" - ], - "filters": { - "etat": [ - "retard", - "absent" - ], - "moduleimpl_id": "", - "est_just": "false" - } - }; - - filterJustificatifs = { - "columns": [ - "entry_date", - "date_debut", - "date_fin", - "etat", - "raison", - "fichier" - ], - "filters": { - "etat": [ - "attente", - "modifie" - ] - } - } - document.getElementById('stats_date_fin').value = assi_date_fin; document.getElementById('stats_date_debut').value = assi_date_debut; - - - - loadAll(); stats(); }) diff --git a/app/templates/assiduites/pages/signal_assiduites_diff.j2 b/app/templates/assiduites/pages/signal_assiduites_diff.j2 index ed801d75..d6066a4b 100644 --- a/app/templates/assiduites/pages/signal_assiduites_diff.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_diff.j2 @@ -1,5 +1,14 @@ +{# + + - TODO : revoir le fonctionnement de cette page (trop lente / complexe) + - Utiliser majoritairement du python + #}

    Signalement différé de l'assiduité {{gr |safe}}

    +
    Attention, cette page utilise des couleurs et conventions différentes + de celles des autres pages ScoDoc: elle sera prochainement modifée, merci de votre patience. +
    +

    {{sem | safe }}

    {{diff | safe}} diff --git a/app/templates/assiduites/widgets/assiduite_bubble.j2 b/app/templates/assiduites/widgets/assiduite_bubble.j2 index 3db6ea25..23004a86 100644 --- a/app/templates/assiduites/widgets/assiduite_bubble.j2 +++ b/app/templates/assiduites/widgets/assiduite_bubble.j2 @@ -3,5 +3,6 @@
    {{date_debut}}
    {{date_fin}}
    État: {{etat}}
    +
    Motif: {{motif}}
    {{saisie}}
    \ No newline at end of file diff --git a/app/templates/assiduites/widgets/conflict.j2 b/app/templates/assiduites/widgets/conflict.j2 index d7c8638e..90883589 100644 --- a/app/templates/assiduites/widgets/conflict.j2 +++ b/app/templates/assiduites/widgets/conflict.j2 @@ -77,12 +77,6 @@ const duration = (endTime - startTime) / 1000 / 60; const percent = (duration / (t_end * 60 - t_start * 60)) * 100 - - if (percent > 100) { - console.log(start, end); - console.log(startTime, endTime) - } - return percent + "%"; } @@ -253,13 +247,11 @@ */ splitAssiduiteModal() { //Préparation du prompt - // TODO utiliser timepicker jquery + utiliser les bornes (t_start et t_end) - const htmlPrompt = `Entrez l'heure de séparation (HH:mm) : - `; + const htmlPrompt = `Entrez l'heure de séparation + `; const fieldSet = document.createElement("fieldset"); - fieldSet.classList.add("fieldsplit"); + fieldSet.classList.add("fieldsplit", "timepicker"); fieldSet.innerHTML = htmlPrompt; //Callback de division @@ -317,11 +309,28 @@ "L'heure de séparation doit être compris dans la période de l'assiduité sélectionnée." ); - openAlertModal("Attention", att, "", "var(--color-warning))"); + openAlertModal("Attention", att, "", "var(--color-warning)"); } }; openPromptModal("Séparation de l'assiduité sélectionnée", fieldSet, success, () => { }, "var(--color-present)"); + // Initialisation du timepicker + const deb = this.selectedAssiduite.date_debut.substring(11,16); + const fin = this.selectedAssiduite.date_fin.substring(11,16); + setTimeout(()=>{ + $('#promptTime').timepicker({ + timeFormat: 'HH:mm', + interval: 60 * tick_delay, + minTime: deb, + startTime: deb, + maxTime: fin, + dynamic: false, + dropdown: true, + scrollbar: false, + + }); + }, 100 + ); } /** @@ -466,4 +475,9 @@ this.editBtn.disabled = true; } } - \ No newline at end of file + + \ No newline at end of file diff --git a/app/templates/assiduites/widgets/minitimeline.j2 b/app/templates/assiduites/widgets/minitimeline.j2 index 335ac701..aa2172bc 100644 --- a/app/templates/assiduites/widgets/minitimeline.j2 +++ b/app/templates/assiduites/widgets/minitimeline.j2 @@ -157,6 +157,11 @@ stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`; bubble.appendChild(stateDiv); + const motifDiv = document.createElement("div"); + stateDiv.className = "assiduite-why"; + stateDiv.textContent = `Motif: ${assiduite.desc?.capitalize()}`; + bubble.appendChild(motifDiv); + const userIdDiv = document.createElement("div"); userIdDiv.className = "assiduite-user_id"; userIdDiv.textContent = `saisie le ${formatDateModal( diff --git a/app/templates/assiduites/widgets/moduleimpl_selector_multiple.j2 b/app/templates/assiduites/widgets/moduleimpl_selector_multiple.j2 new file mode 100644 index 00000000..db77984f --- /dev/null +++ b/app/templates/assiduites/widgets/moduleimpl_selector_multiple.j2 @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 84cc3d33..98e9c563 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -178,62 +178,25 @@ class HTMLBuilder: def bilan_dept(): """Gestionnaire assiduités, page principale""" - # Préparation de la page - H = [ - html_sco_header.sco_header( - page_title="Saisie de l'assiduité", - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=[ - "css/assiduites.css", - ], - ), - """

    Traitement de l'assiduité

    -

    - Pour saisir l'assiduité ou consulter les états, il est recommandé de passer par - le semestre concerné (saisie par jour ou saisie différée). -

    - """, - ] - H.append( - """

    Pour signaler, annuler ou justifier l'assiduité d'un seul étudiant, - choisissez d'abord la personne concernée :

    """ - ) - # Ajout de la barre de recherche d'étudiant (redirection vers bilan etud) - H.append(sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud")) - # Gestion des billets d'absences if current_user.has_permission( Permission.AbsChange ) and sco_preferences.get_preference("handle_billets_abs"): - H.append( - f""" + billets = f"""

    Billets d'absence

    """ - ) - - # Récupération des années d'étude du département - # (afin de sélectionner une année) + else: + billets = "" + # Récupération du département dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first() - annees: list[int] = sorted( - [f.date_debut.year for f in dept.formsemestres], - reverse=True, - ) - annee = scu.annee_scolaire() # Année courante, sera utilisée par défaut - # Génération d'une liste "json" d'années - annees_str: str = "[" - for ann in annees: - annees_str += f"{ann}," - annees_str += "]" # Récupération d'un formsemestre - # (pour n'afficher que les assiduites/justificatifs liés au formsemestre) + # (pour n'afficher que les justificatifs liés au formsemestre) formsemestre_id = request.args.get("formsemestre_id", "") + formsemestre = None if formsemestre_id: try: formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) @@ -241,19 +204,71 @@ def bilan_dept(): except AttributeError: formsemestre_id = "" - # Peuplement du template jinja - H.append( - render_template( - "assiduites/pages/bilan_dept.j2", - dept_id=g.scodoc_dept_id, - annee=annee, - annees=annees_str, - formsemestre_id=formsemestre_id, - group_id=request.args.get("group_id", ""), + # <=> Génération du tableau <=> + + # Récupération des étudiants du département / groupe + etudids: list[int] = [etud.id for etud in dept.etudiants] # cas département + group_ids = request.args.get("group_ids", "") + if group_ids and formsemestre: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids.split(","), + formsemestre_id=formsemestre.id, + select_all_when_unspecified=True, + ) + + if groups_infos.members: + etudids = [m["etudid"] for m in groups_infos.members] + + # justificatifs (en attente ou modifiés avec les semestres associés) + justificatifs_query: Query = Justificatif.query.filter( + Justificatif.etat.in_( + [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE] ), + Justificatif.etudid.in_(etudids), + ) + # Filtrage par semestre si formsemestre_id != "" + if formsemestre: + justificatifs_query = justificatifs_query.filter( + Justificatif.date_debut >= formsemestre.date_debut, + Justificatif.date_debut <= formsemestre.date_fin, + ) + + data = liste_assi.AssiJustifData( + assiduites_query=None, + justificatifs_query=justificatifs_query, + ) + + fname: str = "Bilan Département" + cache_key: str = "tableau-dept" + titre: str = "Justificatifs en attente ou modifiés" + + if formsemestre: + fname += f" {formsemestre.titre_annee()}" + cache_key += f"-{formsemestre.id}" + titre += f" {formsemestre.titre_annee()}" + + if group_ids: + cache_key += f" {group_ids}" + + table = _prepare_tableau( + data, + afficher_etu=True, + filename=fname, + titre=titre, + cache_key=cache_key, + ) + + if not table[0]: + return table[1] + + # Peuplement du template jinja + return render_template( + "assiduites/pages/bilan_dept.j2", + tableau=table[1], + search_etud=sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"), + billets=billets, + sco=ScoData(formsemestre=formsemestre), ) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) @bp.route("/ajout_assiduite_etud", methods=["GET", "POST"]) @@ -601,6 +616,29 @@ def bilan_etud(): sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id), ) + # Récupération des assiduités et justificatifs de l'étudiant + data = liste_assi.AssiJustifData( + etud.assiduites.filter( + Assiduite.etat != scu.EtatAssiduite.PRESENT, Assiduite.est_just == False + ), + etud.justificatifs.filter( + Justificatif.etat.in_( + [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE] + ) + ), + ) + + table = _prepare_tableau( + data, + afficher_etu=False, + filename=f"Bilan assiduité {etud.nomprenom}", + titre="Bilan de l'assiduité de l'étudiant", + cache_key=f"tableau-etud-{etud.id}-bilan", + ) + + if not table[0]: + return table[1] + # Génération de la page return HTMLBuilder( header, @@ -615,6 +653,7 @@ def bilan_etud(): "assi_limit_annee", dept_id=g.scodoc_dept_id, ), + tableau=table[1], ), ).build() @@ -1599,18 +1638,7 @@ def tableau_assiduite_actions(): if obj_type == "assiduite": # Construction du menu module - # XXX ca ne va pas car cela ne prend qu'un semestre - # TODO reprendre le menu de la page ajout_assiduite_etud - formsemestre = objet.get_formsemestre() - if formsemestre: - if objet.moduleimpl_id is not None: - module = objet.moduleimpl_id - elif objet.external_data is not None: - module = objet.external_data.get("module", "") - module = module.lower() if isinstance(module, str) else module - module = _module_selector(formsemestre, module) - else: - module = "pas de semestre correspondant" + module = _module_selector_multiple(objet.etudiant, objet.moduleimpl_id) return render_template( "assiduites/pages/tableau_assiduite_actions.j2", @@ -1818,7 +1846,7 @@ def signal_assiduites_diff(): ) date_fin: datetime.date = date_deb + datetime.timedelta(days=6) - etudiants: list[dict] = [] + etudiants: list[Identite] = [] # --- Vérification de la date --- real_date = scu.is_iso_formated(date, True).date() @@ -1846,15 +1874,9 @@ def signal_assiduites_diff(): # Récupération des étudiants etudiants.extend( - [ - sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] - for m in groups_infos.members - ] + [Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members] ) - # XXX utiliser des instances d'Identite et non des dict - # puis trier avec etud.sort_key - # afin de bien prendre en compte nom usuel etc - etudiants = list(sorted(etudiants, key=lambda x: x["nom"])) + etudiants = list(sorted(etudiants, key=lambda etud: etud.sort_key)) # Génération de l'HTML @@ -1962,9 +1984,7 @@ def signale_evaluation_abs(etudid: int = None, evaluation_id: int = None): "assiduites.ajout_assiduite_etud", etudid=etudid, evaluation_id=evaluation.id, - date_deb=evaluation.date_debut.strftime( - "%Y-%m-%dT%H:%M:%S" - ), + date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"), date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"), moduleimpl_id=evaluation.moduleimpl.id, saisie_eval="true", @@ -2234,6 +2254,32 @@ def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> s ) +def _module_selector_multiple( + etud: Identite, moduleimpl_id: int = None, only_form: FormSemestre = None +) -> str: + modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) + choices = {} + for formsemestre_id in modimpls_by_formsemestre: + formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) + if only_form is not None and formsemestre != only_form: + continue + # indique le nom du semestre dans le menu (optgroup) + choices[formsemestre.titre_annee()] = [ + { + "moduleimpl_id": m.id, + "name": f"{m.module.code} {m.module.abbrev or m.module.titre or ''}", + } + for m in modimpls_by_formsemestre[formsemestre_id] + if m.module.ue.type == UE_STANDARD + ] + + return render_template( + "assiduites/widgets/moduleimpl_selector_multiple.j2", + choices=choices, + moduleimpl_id=moduleimpl_id, + ) + + def _dynamic_module_selector() -> str: """ _dynamic_module_selector retourne l'html/css/javascript du selecteur de module dynamique @@ -2630,6 +2676,8 @@ def _generate_assiduite_bubble(assiduite: Assiduite) -> str: # Récupérer informations saisie saisie: str = assiduite.get_saisie() + motif: str = assiduite.description if assiduite.description else "" + return render_template( "assiduites/widgets/assiduite_bubble.j2", moduleimpl=moduleimpl_infos, @@ -2637,4 +2685,5 @@ def _generate_assiduite_bubble(assiduite: Assiduite) -> str: date_debut=assiduite.date_debut.strftime("%d/%m/%Y %H:%M"), date_fin=assiduite.date_fin.strftime("%d/%m/%Y %H:%M"), saisie=saisie, + motif=motif, ) diff --git a/tests/unit/test_assiduites.py b/tests/unit/test_assiduites.py index 12b34b80..810dd2a0 100644 --- a/tests/unit/test_assiduites.py +++ b/tests/unit/test_assiduites.py @@ -557,50 +557,65 @@ def verifier_filtrage_justificatifs(etud: Identite, justificatifs: list[Justific assert ( scass.filter_by_date(etud.justificatifs, Justificatif).count() == 5 ), "Filtrage 'Toute Date' mauvais 1" - - date = scu.localize_datetime("2022-09-01T10:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-01T10:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etud.justificatifs, Justificatif, date_deb=date).count() == 5 ), "Filtrage 'Toute Date' mauvais 2" - date = scu.localize_datetime("2022-09-05T08:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T08:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etud.justificatifs, Justificatif, date_deb=date).count() == 5 ), "Filtrage 'date début' mauvais 3" - date = scu.localize_datetime("2022-09-05T08:00:01+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T08:00:01+01:00", convert=True) + ) assert ( scass.filter_by_date(etud.justificatifs, Justificatif, date_deb=date).count() == 5 ), "Filtrage 'date début' mauvais 4" - date = scu.localize_datetime("2022-09-05T10:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T10:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etud.justificatifs, Justificatif, date_deb=date).count() == 4 ), "Filtrage 'date début' mauvais 5" - date = scu.localize_datetime("2022-09-01T10:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-01T10:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etud.justificatifs, Justificatif, date_fin=date).count() == 0 ), "Filtrage 'date fin' mauvais 6" - date = scu.localize_datetime("2022-09-05T08:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T08:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etud.justificatifs, Justificatif, date_fin=date).count() == 1 ), "Filtrage 'date fin' mauvais 7" - date = scu.localize_datetime("2022-09-05T10:00:01+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T10:00:01+01:00", convert=True) + ) assert ( scass.filter_by_date(etud.justificatifs, Justificatif, date_fin=date).count() == 2 ), "Filtrage 'date fin' mauvais 8" - date = scu.localize_datetime("2023-01-03T12:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2023-01-03T12:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etud.justificatifs, Justificatif, date_fin=date).count() == 5 @@ -624,8 +639,12 @@ def editer_supprimer_justificatif(etud: Identite): # Modification de l'état justi.etat = scu.EtatJustificatif.MODIFIE # Modification du moduleimpl - justi.date_debut = scu.localize_datetime("2023-02-03T11:00:01+01:00") - justi.date_fin = scu.localize_datetime("2023-02-03T12:00:01+01:00") + justi.date_debut = scu.localize_datetime( + scu.is_iso_formated("2023-02-03T11:00:01+01:00", convert=True) + ) + justi.date_fin = scu.localize_datetime( + scu.is_iso_formated("2023-02-03T12:00:01+01:00", convert=True) + ) db.session.add(justi) db.session.commit() @@ -639,7 +658,9 @@ def editer_supprimer_justificatif(etud: Identite): scass.filter_by_date( etud.justificatifs, Justificatif, - date_deb=scu.localize_datetime("2023-02-01T11:00:00+01:00"), + date_deb=scu.localize_datetime( + scu.is_iso_formated("2023-02-01T11:00:00+01:00", convert=True) + ), ).count() == 1 ), "Edition de justificatif mauvais 2" @@ -930,44 +951,60 @@ def verifier_comptage_et_filtrage_assiduites( scass.filter_by_date(etu2.assiduites, Assiduite).count() == 7 ), "Filtrage 'Date début' mauvais 1" - date = scu.localize_datetime("2022-09-01T10:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-01T10:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etu2.assiduites, Assiduite, date_deb=date).count() == 7 ), "Filtrage 'Date début' mauvais 2" - date = scu.localize_datetime("2022-09-05T10:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T10:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etu2.assiduites, Assiduite, date_deb=date).count() == 7 ), "Filtrage 'Date début' mauvais 3" - date = scu.localize_datetime("2022-09-05T16:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T16:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etu2.assiduites, Assiduite, date_deb=date).count() == 4 ), "Filtrage 'Date début' mauvais 4" # Date Fin - date = scu.localize_datetime("2022-09-01T10:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-01T10:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 0 ), "Filtrage 'Date fin' mauvais 1" - date = scu.localize_datetime("2022-09-05T10:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T10:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 1 ), "Filtrage 'Date fin' mauvais 2" - date = scu.localize_datetime("2022-09-05T10:00:01+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T10:00:01+01:00", convert=True) + ) assert ( scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 2 ), "Filtrage 'Date fin' mauvais 3" - date = scu.localize_datetime("2022-09-05T16:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2022-09-05T16:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 3 ), "Filtrage 'Date fin' mauvais 4" - date = scu.localize_datetime("2023-01-04T16:00+01:00") + date = scu.localize_datetime( + scu.is_iso_formated("2023-01-04T16:00+01:00", convert=True) + ) assert ( scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 7 ), "Filtrage 'Date fin' mauvais 5" From 9c1c316f1436899f26036fb18a4aba0c64b254ba Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 14:49:36 +0100 Subject: [PATCH 19/55] =?UTF-8?q?RGPD:=20protection=20optionnelle=20des=20?= =?UTF-8?q?donn=C3=A9es=20perso=20=C3=A9tudiantes=20(ViewEtudData)=20sur?= =?UTF-8?q?=20fiche=5Fetud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/jury.py | 2 +- app/but/jury_but_pv.py | 2 +- app/but/jury_but_view.py | 2 +- app/comp/res_classic.py | 2 +- app/models/etudiants.py | 43 +- app/pe/pe_jurype.py | 6 +- app/scodoc/html_sidebar.py | 2 +- app/scodoc/sco_abs_billets.py | 2 +- app/scodoc/sco_abs_notification.py | 17 +- app/scodoc/sco_archives_etud.py | 8 +- app/scodoc/sco_cursus_dut.py | 1 - app/scodoc/sco_debouche.py | 4 +- app/scodoc/sco_etape_apogee_view.py | 6 +- app/scodoc/sco_etape_bilan.py | 2 +- app/scodoc/sco_etud.py | 97 +--- app/scodoc/sco_evaluation_check_abs.py | 2 +- app/scodoc/sco_export_results.py | 7 +- app/scodoc/sco_find_etud.py | 11 +- app/scodoc/sco_formsemestre_exterieurs.py | 4 +- app/scodoc/sco_formsemestre_inscriptions.py | 16 +- app/scodoc/sco_formsemestre_status.py | 6 +- app/scodoc/sco_formsemestre_validation.py | 34 +- app/scodoc/sco_groups_view.py | 6 +- app/scodoc/sco_inscr_passage.py | 2 +- app/scodoc/sco_lycee.py | 6 +- app/scodoc/sco_moduleimpl_inscriptions.py | 8 +- app/scodoc/sco_page_etud.py | 518 ++++++++++-------- app/scodoc/sco_permissions.py | 1 + app/scodoc/sco_poursuite_dut.py | 2 +- app/scodoc/sco_pv_forms.py | 4 +- app/scodoc/sco_report.py | 86 ++- app/scodoc/sco_roles_default.py | 12 +- app/scodoc/sco_trombino.py | 2 +- app/scodoc/sco_utils.py | 23 +- app/static/css/scodoc.css | 7 +- app/templates/bul_head.j2 | 4 +- app/templates/entreprises/fiche_entreprise.j2 | 2 +- app/templates/scolar/partition_editor.j2 | 2 +- app/templates/sidebar.j2 | 2 +- app/views/absences.py | 2 +- app/views/notes.py | 16 +- app/views/scolar.py | 36 +- ...88ff8970_config_permission_viewetuddata.py | 44 ++ tests/unit/test_etudiants.py | 5 - 44 files changed, 609 insertions(+), 457 deletions(-) create mode 100644 migrations/versions/3fa988ff8970_config_permission_viewetuddata.py diff --git a/app/api/jury.py b/app/api/jury.py index e71531eb..864dfe22 100644 --- a/app/api/jury.py +++ b/app/api/jury.py @@ -66,7 +66,7 @@ def _news_delete_jury_etud(etud: Identite): "génère news sur effacement décision" # n'utilise pas g.scodoc_dept, pas toujours dispo en mode API url = url_for( - "scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=etud.departement.acronym, etudid=etud.id ) ScolarNews.add( typ=ScolarNews.NEWS_JURY, diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py index 9b970a61..8ae09800 100644 --- a/app/but/jury_but_pv.py +++ b/app/but/jury_but_pv.py @@ -154,7 +154,7 @@ def pvjury_table_but( "_nom_target_attrs": f'class="etudinfo" id="{etud.id}"', "_nom_td_attrs": f'id="{etud.id}" class="etudinfo"', "_nom_target": url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id, ), diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 544d13f2..8536ac63 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -447,7 +447,7 @@ def jury_but_semestriel(
    {etud.nomprenom}
    diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 89ff95e7..673668b9 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -234,7 +234,7 @@ class ResultatsSemestreClassic(NotesTableCompat): raise ScoValueError( f"""

    Coefficient de l'UE capitalisée {ue.acronyme} impossible à déterminer pour l'étudiant {etud.nom_disp()}

    Il faut " @@ -176,7 +179,7 @@ class Identite(models.ScoDocModel): def url_fiche(self) -> str: "url de la fiche étudiant" return url_for( - "scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id + "scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id ) @classmethod @@ -433,9 +436,10 @@ class Identite(models.ScoDocModel): "prenom_etat_civil": self.prenom_etat_civil, } - def to_dict_scodoc7(self) -> dict: + def to_dict_scodoc7(self, restrict=False) -> dict: """Représentation dictionnaire, - compatible ScoDoc7 mais sans infos admission + compatible ScoDoc7 mais sans infos admission. + Si restrict, cache les infos "personnelles" si pas permission ViewEtudData """ e_dict = self.__dict__.copy() # dict(self.__dict__) e_dict.pop("_sa_instance_state", None) @@ -446,7 +450,7 @@ class Identite(models.ScoDocModel): e_dict["nomprenom"] = self.nomprenom adresse = self.adresses.first() if adresse: - e_dict.update(adresse.to_dict()) + e_dict.update(adresse.to_dict(restrict=restrict)) return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty def to_dict_bul(self, include_urls=True): @@ -481,7 +485,7 @@ class Identite(models.ScoDocModel): if include_urls and has_request_context(): # test request context so we can use this func in tests under the flask shell d["fiche_url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id ) d["photo_url"] = sco_photos.get_etud_photo_url(self.id) adresse = self.adresses.first() @@ -825,12 +829,25 @@ class Adresse(models.ScoDocModel): ) description = db.Column(db.Text) - def to_dict(self, convert_nulls_to_str=False): - """Représentation dictionnaire,""" + # Champs "protégés" par ViewEtudData (RGPD) + protected_attrs = { + "emailperso", + "domicile", + "codepostaldomicile", + "villedomicile", + "telephone", + "telephonemobile", + "fax", + } + + def to_dict(self, convert_nulls_to_str=False, restrict=False): + """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) if convert_nulls_to_str: - return {k: e[k] or "" for k in e} + e = {k: v or "" for k, v in e.items()} + if restrict: + e = {k: v for (k, v) in e.items() if k not in self.protected_attrs} return e @@ -885,12 +902,16 @@ class Admission(models.ScoDocModel): # classement (1..Ngr) par le jury dans le groupe APB apb_classement_gr = db.Column(db.Integer) + # Tous les champs sont "protégés" par ViewEtudData (RGPD) + # sauf: + not_protected_attrs = {"bac", "specialite", "anne_bac"} + def get_bac(self) -> Baccalaureat: "Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères." return Baccalaureat(self.bac, specialite=self.specialite) - def to_dict(self, no_nulls=False): - """Représentation dictionnaire,""" + def to_dict(self, no_nulls=False, restrict=False): + """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" d = dict(self.__dict__) d.pop("_sa_instance_state", None) if no_nulls: @@ -905,6 +926,8 @@ class Admission(models.ScoDocModel): d[key] = 0 elif isinstance(col_type, sqlalchemy.Boolean): d[key] = False + if restrict: + d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs} return d @classmethod diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py index 01687f7c..d6416c2e 100644 --- a/app/pe/pe_jurype.py +++ b/app/pe/pe_jurype.py @@ -455,7 +455,9 @@ class JuryPE(object): reponse = False etud = self.get_cache_etudInfo_d_un_etudiant(etudid) - (_, parcours) = sco_report.get_code_cursus_etud(etud) + (_, parcours) = sco_report.get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) if ( len(codes_cursus.CODES_SEM_REO & set(parcours.values())) > 0 ): # Eliminé car NAR apparait dans le parcours @@ -527,7 +529,7 @@ class JuryPE(object): etud = self.get_cache_etudInfo_d_un_etudiant(etudid) (code, parcours) = sco_report.get_code_cursus_etud( - etud + etud["etudid"], sems=etud["sems"] ) # description = '1234:A', parcours = {1:ADM, 2:NAR, ...} sonDernierSemestreValide = max( [ diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index 9806b115..dce59627 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -107,7 +107,7 @@ def sidebar(etudid: int = None): etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] params.update(etud) params["fiche_url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ) # compte les absences du semestre en cours H.append( diff --git a/app/scodoc/sco_abs_billets.py b/app/scodoc/sco_abs_billets.py index 18fe777f..4a9da0af 100644 --- a/app/scodoc/sco_abs_billets.py +++ b/app/scodoc/sco_abs_billets.py @@ -129,7 +129,7 @@ def table_billets( ] = f'id="{billet.etudiant.id}" class="etudinfo"' if with_links: billet_dict["_nomprenom_target"] = url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=billet_dict["etudid"], ) diff --git a/app/scodoc/sco_abs_notification.py b/app/scodoc/sco_abs_notification.py index b97f4418..852f586d 100644 --- a/app/scodoc/sco_abs_notification.py +++ b/app/scodoc/sco_abs_notification.py @@ -34,7 +34,7 @@ Il suffit d'appeler abs_notify() après chaque ajout d'absence. import datetime from typing import Optional -from flask import current_app, g, url_for +from flask import g, url_for from flask_mail import Message from app import db @@ -42,6 +42,7 @@ from app import email from app import log from app.auth.models import User from app.models.absences import AbsenceNotification +from app.models.etudiants import Identite from app.models.events import Scolog from app.models.formsemestre import FormSemestre import app.scodoc.notesdb as ndb @@ -175,9 +176,15 @@ def abs_notify_get_destinations( if prefs["abs_notify_email"]: destinations.append(prefs["abs_notify_email"]) if prefs["abs_notify_etud"]: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - if etud["email_default"]: - destinations.append(etud["email_default"]) + etud = Identite.get_etud(etudid) + adresse = etud.adresses.first() + if adresse: + # Mail à utiliser pour les envois vers l'étudiant: + # choix qui pourrait être controlé par une preference + # ici priorité au mail institutionnel: + email_default = adresse.email or adresse.emailperso + if email_default: + destinations.append(email_default) # Notification (à chaque fois) des resp. de modules ayant des évaluations # à cette date @@ -271,7 +278,7 @@ def abs_notification_message( values["nbabsjust"] = nbabsjust values["nbabsnonjust"] = nbabs - nbabsjust values["url_ficheetud"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True ) template = prefs["abs_notification_mail_tmpl"] diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 1dca9f02..42fddde2 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -177,7 +177,7 @@ def etud_upload_file_form(etudid): return "\n".join(H) + tf[1] + html_sco_header.sco_footer() elif tf[0] == -1: return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) else: data = tf[2]["datafile"].read() @@ -188,7 +188,7 @@ def etud_upload_file_form(etudid): etud_archive_id, data, filename, description=descr ) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -228,7 +228,7 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False): ), dest_url="", cancel_url=url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, head_message="annulation", @@ -239,7 +239,7 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False): ETUDS_ARCHIVER.delete_archive(archive_id, dept_id=etud["dept_id"]) flash("Archive supprimée") return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) diff --git a/app/scodoc/sco_cursus_dut.py b/app/scodoc/sco_cursus_dut.py index b9e26e32..47e7a5fe 100644 --- a/app/scodoc/sco_cursus_dut.py +++ b/app/scodoc/sco_cursus_dut.py @@ -39,7 +39,6 @@ from app import log from app.scodoc.scolog import logdb from app.scodoc import sco_cache, sco_etud from app.scodoc import sco_formsemestre -from app.scodoc import sco_formations from app.scodoc.codes_cursus import ( CMP, ADC, diff --git a/app/scodoc/sco_debouche.py b/app/scodoc/sco_debouche.py index cc08e8d9..f027b553 100644 --- a/app/scodoc/sco_debouche.py +++ b/app/scodoc/sco_debouche.py @@ -134,10 +134,10 @@ def table_debouche_etudids(etudids, keep_numeric=True): "nom": etud["nom"], "prenom": etud["prenom"], "_nom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), "_prenom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), "_nom_td_attrs": 'id="%s" class="etudinfo"' % (etud["etudid"]), # 'debouche' : etud['debouche'], diff --git a/app/scodoc/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py index 6247d986..da833d5c 100644 --- a/app/scodoc/sco_etape_apogee_view.py +++ b/app/scodoc/sco_etape_apogee_view.py @@ -542,7 +542,9 @@ def view_scodoc_etuds(semset_id, title="", nip_list="", fmt="html"): etuds = [sco_etud.get_etud_info(code_nip=nip, filled=True)[0] for nip in nips] for e in etuds: - tgt = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"]) + tgt = url_for( + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"] + ) e["_nom_target"] = tgt e["_prenom_target"] = tgt e["_nom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """ @@ -770,7 +772,7 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"): e["in_scodoc_str"] = {True: "oui", False: "non"}[e["in_scodoc"]] if e["in_scodoc"]: e["_in_scodoc_str_target"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, code_nip=e["nip"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"] ) e.update(sco_etud.get_etud_info(code_nip=e["nip"], filled=True)[0]) e["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"],) diff --git a/app/scodoc/sco_etape_bilan.py b/app/scodoc/sco_etape_bilan.py index 575d2596..86da7166 100644 --- a/app/scodoc/sco_etape_bilan.py +++ b/app/scodoc/sco_etape_bilan.py @@ -692,7 +692,7 @@ class EtapeBilan: @staticmethod def link_etu(etudid, nom): return '%s' % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), nom, ) diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index e7041021..f6b8a18d 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -64,7 +64,7 @@ def format_etud_ident(etud: dict): Note: par rapport à Identite.to_dict_bul(), ajoute les champs: - 'email_default', 'nom_disp', 'nom_usuel', 'civilite_etat_civil_str', 'ne', 'civilite_str' + 'nom_disp', 'nom_usuel', 'civilite_etat_civil_str', 'ne', 'civilite_str' """ etud["nom"] = format_nom(etud["nom"]) if "nom_usuel" in etud: @@ -98,10 +98,6 @@ def format_etud_ident(etud: dict): etud["ne"] = "e" else: # 'X' etud["ne"] = "(e)" - # Mail à utiliser pour les envois vers l'étudiant: - # choix qui pourrait être controé par une preference - # ici priorité au mail institutionnel: - etud["email_default"] = etud.get("email", "") or etud.get("emailperso", "") def force_uppercase(s): @@ -117,36 +113,6 @@ def _format_etat_civil(etud: dict) -> str: return etud["nomprenom"] -def format_lycee(nomlycee): - nomlycee = nomlycee.strip() - s = nomlycee.lower() - if s[:5] == "lycee" or s[:5] == "lycée": - return nomlycee[5:] - else: - return nomlycee - - -def format_telephone(n): - if n is None: - return "" - if len(n) < 7: - return n - else: - n = n.replace(" ", "").replace(".", "") - i = 0 - r = "" - j = len(n) - 1 - while j >= 0: - r = n[j] + r - if i % 2 == 1 and j != 0: - r = " " + r - i += 1 - j -= 1 - if len(r) == 13 and r[0] != "0": - r = "0" + r - return r - - def format_pays(s): "laisse le pays seulement si != FRANCE" if s.upper() != "FRANCE": @@ -283,14 +249,14 @@ def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True) listh.append( f"""Autre étudiant: {e['nom']} {e['prenom']}""" ) if etudid: OK = "retour à la fiche étudiant" - dest_endpoint = "scolar.ficheEtud" + dest_endpoint = "scolar.fiche_etud" parameters = {"etudid": etudid} else: if "tf_submitted" in args: @@ -619,7 +585,7 @@ def create_etud(cnx, args: dict = None): etud_dict = etudident_list(cnx, {"etudid": etudid})[0] fill_etuds_info([etud_dict]) etud_dict["url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ) ScolarNews.add( typ=ScolarNews.NEWS_INSCR, @@ -724,19 +690,28 @@ def get_etablissements(): def get_lycee_infos(codelycee): - E = get_etablissements() - return E.get(codelycee, None) + etablissements = get_etablissements() + return etablissements.get(codelycee, None) -def format_lycee_from_code(codelycee): +def format_lycee_from_code(codelycee: str) -> str: "Description lycee à partir du code" - E = get_etablissements() - if codelycee in E: - e = E[codelycee] + etablissements = get_etablissements() + if codelycee in etablissements: + e = etablissements[codelycee] nomlycee = e["name"] - return "%s (%s)" % (nomlycee, e["commune"]) + return f"{nomlycee} ({e['commune']})" + return f"{codelycee} (établissement inconnu)" + + +def format_lycee(nomlycee: str) -> str: + "mise en forme nom de lycée" + nomlycee = nomlycee.strip() + s = nomlycee.lower() + if s[:5] == "lycee" or s[:5] == "lycée": + return nomlycee[5:] else: - return "%s (établissement inconnu)" % codelycee + return nomlycee def etud_add_lycee_infos(etud): @@ -821,36 +796,6 @@ def fill_etuds_info(etuds: list[dict], add_admission=True): # nettoyage champs souvent vides etud["codepostallycee"] = etud.get("codepostallycee", "") or "" etud["nomlycee"] = etud.get("nomlycee", "") or "" - if etud.get("nomlycee"): - etud["ilycee"] = "Lycée " + format_lycee(etud["nomlycee"]) - if etud["villelycee"]: - etud["ilycee"] += " (%s)" % etud.get("villelycee", "") - etud["ilycee"] += "
    " - else: - if etud.get("codelycee"): - etud["ilycee"] = format_lycee_from_code(etud["codelycee"]) - else: - etud["ilycee"] = "" - rap = "" - if etud.get("rapporteur") or etud.get("commentaire"): - rap = "Note du rapporteur" - if etud.get("rapporteur"): - rap += " (%s)" % etud["rapporteur"] - rap += ": " - if etud.get("commentaire"): - rap += "%s" % etud["commentaire"] - etud["rap"] = rap - - if etud.get("telephone"): - etud["telephonestr"] = "Tél.: " + format_telephone(etud["telephone"]) - else: - etud["telephonestr"] = "" - if etud.get("telephonemobile"): - etud["telephonemobilestr"] = "Mobile: " + format_telephone( - etud["telephonemobile"] - ) - else: - etud["telephonemobilestr"] = "" def etud_inscriptions_infos(etudid: int, ne="") -> dict: diff --git a/app/scodoc/sco_evaluation_check_abs.py b/app/scodoc/sco_evaluation_check_abs.py index bd6b8ded..eb35312d 100644 --- a/app/scodoc/sco_evaluation_check_abs.py +++ b/app/scodoc/sco_evaluation_check_abs.py @@ -156,7 +156,7 @@ def evaluation_check_absences_html( H.append( f"""

  • {etud.nomprenom}""" ) diff --git a/app/scodoc/sco_export_results.py b/app/scodoc/sco_export_results.py index d9a89b2b..beaf7378 100644 --- a/app/scodoc/sco_export_results.py +++ b/app/scodoc/sco_export_results.py @@ -173,9 +173,10 @@ def _build_results_list(dpv_by_sem, etuds_infos): "nom_usuel": etud["nom_usuel"], "prenom": etud["prenom"], "civilite_str": etud["civilite_str"], - "_nom_target": "%s" - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - "_nom_td_attrs": 'id="%s" class="etudinfo"' % etudid, + "_nom_target": url_for( + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + "_nom_td_attrs": f'id="{etudid}" class="etudinfo"', "bac": bac.abbrev(), "parcours": dec["parcours"], } diff --git a/app/scodoc/sco_find_etud.py b/app/scodoc/sco_find_etud.py index 579992da..d86f4dee 100644 --- a/app/scodoc/sco_find_etud.py +++ b/app/scodoc/sco_find_etud.py @@ -145,7 +145,7 @@ def search_etud_in_dept(expnom=""): if "dest_url" in vals: endpoint = vals["dest_url"] else: - endpoint = "scolar.ficheEtud" + endpoint = "scolar.fiche_etud" if "parameters_keys" in vals: for key in vals["parameters_keys"].split(","): url_args[key] = vals[key] @@ -328,8 +328,9 @@ def table_etud_in_accessible_depts(expnom=None): """ result, accessible_depts = search_etud_in_accessible_depts(expnom=expnom) H = [ - """
    """, - """

    Recherche multi-département de "%s"

    """ % expnom, + f"""
    +

    Recherche multi-département de "{expnom}"

    + """, ] for etuds in result: if etuds: @@ -337,9 +338,9 @@ def table_etud_in_accessible_depts(expnom=None): # H.append('

    Département %s

    ' % DeptId) for e in etuds: e["_nomprenom_target"] = url_for( - "scolar.ficheEtud", scodoc_dept=dept_id, etudid=e["etudid"] + "scolar.fiche_etud", scodoc_dept=dept_id, etudid=e["etudid"] ) - e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) + e["_nomprenom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """ tab = GenTable( titles={"nomprenom": "Étudiants en " + dept_id}, diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index 22b600af..7c2cc31b 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -102,7 +102,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id): scodoc_dept=g.scodoc_dept, etudid=etudid, only_ext=1) }"> inscrire à un autre semestre"

    -

    Étudiant {etud.nomprenom}

    """, @@ -221,7 +221,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id): tf[2]["formation_id"] = orig_sem["formation_id"] formsemestre_ext_create(etudid, tf[2]) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index b1d73150..11c6b07d 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -400,7 +400,7 @@ def formsemestre_inscription_with_modules_form(etudid, only_ext=False): H.append("

    aucune session de formation !

    ") H.append( f"""

    ou

    retour à la fiche de {etud.nomprenom}""" ) return "\n".join(H) + footer @@ -440,7 +440,7 @@ def formsemestre_inscription_with_modules( dans le semestre {formsemestre.titre_mois()}

      -
    • retour à la fiche de {etud.nomprenom}
    • Aucune modification à effectuer

      retour à la fiche étudiant

      """ - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + % url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) return "\n".join(H) + footer @@ -755,7 +755,7 @@ function chkbx_select(field_id, state) { etudid, modulesimpls_ainscrire, modulesimpls_adesinscrire, - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), ) ) return "\n".join(H) + footer @@ -820,7 +820,7 @@ def do_moduleimpl_incription_options(

      Retour à la fiche étudiant

      """ - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + % url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), html_sco_header.sco_footer(), ] return "\n".join(H) @@ -885,7 +885,7 @@ def formsemestre_inscrits_ailleurs(formsemestre_id): '
    • %s : ' % ( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"], ), diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 2b89c1f7..6aa773a7 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1457,7 +1457,7 @@ def formsemestre_warning_etuds_sans_note( noms = ", ".join( [ f"""{etud.nomprenom}""" for etud in etuds ] @@ -1519,13 +1519,13 @@ def formsemestre_note_etuds_sans_notes( a déjà des notes""" ) return redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) else: noms = "
    • ".join( [ f"""{etud.nomprenom}""" for etud in etuds ] diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index d11b334c..c8c95562 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -117,8 +117,8 @@ def formsemestre_validation_etud_form( if read_only: check = True - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) + etud_d = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + Se = sco_cursus.get_situation_etud_cursus(etud_d, formsemestre_id) if not Se.sem["etat"]: raise ScoValueError("validation: semestre verrouille") @@ -132,7 +132,7 @@ def formsemestre_validation_etud_form( H = [ html_sco_header.sco_header( - page_title=f"Parcours {etud['nomprenom']}", + page_title=f"Parcours {etud.nomprenom}", javascripts=["js/recap_parcours.js"], ) ] @@ -177,26 +177,22 @@ def formsemestre_validation_etud_form( H.append('
      ') if not check: H.append( - '

      %s: validation %s%s

      Parcours: %s' - % ( - etud["nomprenom"], - Se.parcours.SESSION_NAME_A, - Se.parcours.SESSION_NAME, - Se.get_cursus_descr(), - ) + f"""

      {etud.nomprenom}: validation { + Se.parcours.SESSION_NAME_A}{Se.parcours.SESSION_NAME + }

      Parcours: {Se.get_cursus_descr()} + """ ) else: H.append( - '

      Parcours de %s

      %s' - % (etud["nomprenom"], Se.get_cursus_descr()) + f"""

      Parcours de {etud.nomprenom}

      {Se.get_cursus_descr()}""" ) H.append( - '
      %s
      ' - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]), - ) + f"""{etud.photo_html(title="fiche de " + etud.nomprenom)} + + """ ) etud_etat = nt.get_etud_etat(etudid) @@ -210,7 +206,7 @@ def formsemestre_validation_etud_form(
      Impossible de statuer sur cet étudiant: il est démissionnaire ou défaillant (voir sa fiche)
      """ @@ -289,7 +285,7 @@ def formsemestre_validation_etud_form( etudid=etudid, origin_formsemestre_id=formsemestre_id ).all() if autorisations: - H.append(". Autorisé%s à s'inscrire en " % etud["ne"]) + H.append(f". Autorisé{etud.e} à s'inscrire en ") H.append(", ".join([f"S{aut.semestre_id}" for aut in autorisations]) + ".") H.append("

      ") diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index aacbae64..c02a3e88 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -561,7 +561,7 @@ def groups_table( else: etud["_emailperso_target"] = "" fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_disp_target"] = fiche_url etud["_nom_disp_order"] = etud_sort_key(etud) @@ -829,7 +829,9 @@ def groups_table( etud, groups_infos.formsemestre_id ) m["parcours"] = Se.get_cursus_descr() - m["code_cursus"], _ = sco_report.get_code_cursus_etud(etud) + m["code_cursus"], _ = sco_report.get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) rows = [[m.get(k, "") for k in keys] for m in groups_infos.members] title = "etudiants_%s" % groups_infos.groups_filename xls = sco_excel.excel_simple_table(titles=titles, lines=rows, sheet_name=title) diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index 8a9ef08f..f97e0d44 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -669,7 +669,7 @@ def etuds_select_boxes( elink = """%s""" % ( c, url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"], ), diff --git a/app/scodoc/sco_lycee.py b/app/scodoc/sco_lycee.py index 606fabf2..da274dc9 100644 --- a/app/scodoc/sco_lycee.py +++ b/app/scodoc/sco_lycee.py @@ -143,7 +143,9 @@ def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False) if not no_links: for etud in etuds: fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", + scodoc_dept=g.scodoc_dept, + etudid=etud["etudid"], ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url @@ -232,7 +234,7 @@ def js_coords_lycees(etuds_by_lycee): '%s' % ( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"], ), diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 487368f0..c110dacf 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -186,7 +186,7 @@ def moduleimpl_inscriptions_edit( H.append( f""" H.append( f""" str: [ f"""{etud.nomprenom}""" for etud in sorted(etuds, key=attrgetter("sort_key")) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 2a23059f..1bdb148c 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -25,38 +25,37 @@ # ############################################################################## -"""ScoDoc ficheEtud +"""ScoDoc fiche_etud Fiche description d'un étudiant et de son parcours """ -from flask import abort, url_for, g, render_template, request +from flask import url_for, g, render_template, request from flask_login import current_user +import sqlalchemy as sa -from app import db, log +from app import log +from app.auth.models import User from app.but import cursus_but -from app.models.etudiants import make_etud_args -from app.models import Identite, FormSemestre, ScoDocSiteConfig -from app.scodoc import html_sco_header -from app.scodoc import htmlutils -from app.scodoc import sco_archives_etud -from app.scodoc import sco_bac -from app.scodoc import codes_cursus -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_status -from app.scodoc import sco_groups -from app.scodoc import sco_cursus -from app.scodoc import sco_permissions_check -from app.scodoc import sco_photos -from app.scodoc import sco_users -from app.scodoc import sco_report -from app.scodoc import sco_etud +from app.models import Adresse, EtudAnnotation, FormSemestre, Identite, ScoDocSiteConfig +from app.scodoc import ( + codes_cursus, + html_sco_header, + htmlutils, + sco_archives_etud, + sco_bac, + sco_cursus, + sco_etud, + sco_formsemestre_status, + sco_groups, + sco_permissions_check, + sco_report, +) from app.scodoc.sco_bulletins import etud_descr_situation_semestre from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table from app.scodoc.sco_permissions import Permission import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb def _menu_scolarite( @@ -157,29 +156,18 @@ def _menu_scolarite( ) -def ficheEtud(etudid=None): +def fiche_etud(etudid=None): "fiche d'informations sur un etudiant" - authuser = current_user - cnx = ndb.GetDBConnexion() - if etudid: - try: # pour les bookmarks avec d'anciens ids... - etudid = int(etudid) - except ValueError: - raise ScoValueError("id invalide !") from ValueError - # la sidebar est differente s'il y a ou pas un etudid - # voir html_sidebar.sidebar() - g.etudid = etudid - args = make_etud_args(etudid=etudid) - etuds = sco_etud.etudident_list(cnx, args) - if not etuds: - log(f"ficheEtud: etudid={etudid!r} request.args={request.args!r}") - raise ScoValueError("Étudiant inexistant !") - etud_ = etuds[0] # transition: etud_ à éliminer et remplacer par etud - etudid = etud_["etudid"] - etud = Identite.get_etud(etudid) - sco_etud.fill_etuds_info([etud_]) - # - info = etud_ + restrict_etud_data = not current_user.has_permission(Permission.ViewEtudData) + try: + etud = Identite.get_etud(etudid) + except Exception as exc: + log(f"fiche_etud: etudid={etudid!r} request.args={request.args!r}") + raise ScoValueError("Étudiant inexistant !") from exc + # la sidebar est differente s'il y a ou pas un etudid + # voir html_sidebar.sidebar() + g.etudid = etudid + info = etud.to_dict_scodoc7(restrict=restrict_etud_data) if etud.prenom_etat_civil: info["etat_civil"] = ( "

      Etat-civil: " @@ -193,45 +181,24 @@ def ficheEtud(etudid=None): else: info["etat_civil"] = "" info["ScoURL"] = scu.ScoURL() - info["authuser"] = authuser - info["info_naissance"] = info["date_naissance"] - if info["lieu_naissance"]: - info["info_naissance"] += " à " + info["lieu_naissance"] - if info["dept_naissance"]: - info["info_naissance"] += f" ({info['dept_naissance']})" - info["etudfoto"] = sco_photos.etud_photo_html(etud_) - if ( - (not info["domicile"]) - and (not info["codepostaldomicile"]) - and (not info["villedomicile"]) - ): - info["domicile"] = "inconnue" - if info["paysdomicile"]: - pays = sco_etud.format_pays(info["paysdomicile"]) - if pays: - info["paysdomicile"] = "(%s)" % pays - else: - info["paysdomicile"] = "" - if info["telephone"] or info["telephonemobile"]: - info["telephones"] = "
      %s    %s" % ( - info["telephonestr"], - info["telephonemobilestr"], - ) + info["authuser"] = current_user + if restrict_etud_data: + info["info_naissance"] = "" + adresse = None else: - info["telephones"] = "" - # e-mail: - if info["email_default"]: - info["emaillink"] = ", ".join( - [ - '%s' % (m, m) - for m in [etud_["email"], etud_["emailperso"]] - if m - ] - ) - else: - info["emaillink"] = "(pas d'adresse e-mail)" + info["info_naissance"] = info["date_naissance"] + if info["lieu_naissance"]: + info["info_naissance"] += " à " + info["lieu_naissance"] + if info["dept_naissance"]: + info["info_naissance"] += f" ({info['dept_naissance']})" + adresse = etud.adresses.first() + info.update(_format_adresse(adresse)) + + info.update(etud.inscription_descr()) + info["etudfoto"] = etud.photo_html() + # Champ dépendant des permissions: - if authuser.has_permission(Permission.EtudChangeAdr): + if current_user.has_permission(Permission.EtudChangeAdr): info[ "modifadresse" ] = f"""{descr["situation"]}""" else: e = {"etudid": etudid} - sco_groups.etud_add_group_infos( - e, - sem["formsemestre_id"], - only_to_show=True, - ) + sco_groups.etud_add_group_infos(e, formsemestre.id, only_to_show=True) grlinks = [] for partition in e["partitions"].values(): @@ -289,16 +252,16 @@ def ficheEtud(etudid=None): ) grlink = ", ".join(grlinks) # infos ajoutées au semestre dans le parcours (groupe, menu) - menu = _menu_scolarite(authuser, formsemestre, etudid, sem["ins"]["etat"]) + menu = _menu_scolarite(current_user, formsemestre, etudid, inscription.etat) if menu: - sem_info[sem["formsemestre_id"]] = ( + sem_info[formsemestre.id] = ( "
      " + grlink + "" + menu + "
      " ) else: - sem_info[sem["formsemestre_id"]] = grlink + sem_info[formsemestre.id] = grlink - if info["sems"]: - Se = sco_cursus.get_situation_etud_cursus(etud_, info["last_formsemestre_id"]) + if inscriptions: + Se = sco_cursus.get_situation_etud_cursus(info, info["last_formsemestre_id"]) info["liste_inscriptions"] = formsemestre_recap_parcours_table( Se, etudid, @@ -318,20 +281,19 @@ def ficheEtud(etudid=None): """ ) - last_formsemestre: FormSemestre = db.session.get( - FormSemestre, info["sems"][0]["formsemestre_id"] - ) + last_formsemestre: FormSemestre = inscriptions[0].formsemestre if last_formsemestre.formation.is_apc() and last_formsemestre.semestre_id > 2: info[ "link_bul_pdf" ] += f"""
      Visualiser les compétences BUT """ - if authuser.has_permission(Permission.EtudInscrit): + if current_user.has_permission(Permission.EtudInscrit): info[ "link_inscrire_ailleurs" ] = f"""Étudiant{info["ne"]} non inscrit{info["ne"]}"""] - if authuser.has_permission(Permission.EtudInscrit): + l = [f"""

      Étudiant{etud.e} non inscrit{etud.e}"""] + if current_user.has_permission(Permission.EtudInscrit): l.append( f"""%s' - % ( - etudid, - a["id"], - scu.icontag( + annotations_list = [] + annotations = EtudAnnotation.query.filter_by(etudid=etud.id).order_by( + sa.desc(EtudAnnotation.date) + ) + for annot in annotations: + del_link = ( + f"""{ + scu.icontag( "delete_img", border="0", alt="suppress", title="Supprimer cette annotation", - ), ) - ) - author = sco_users.user_info(a["author"]) - alist.append( - f"""Le {a['date']} par {author['prenomnom']} : - {a['comment']}{a['dellink']} + }""" + if sco_permissions_check.can_suppress_annotation(annot.id) + else "" + ) + + author = User.query.filter_by(user_name=annot.author).first() + annotations_list.append( + f"""Le {annot.date.strftime("%d/%m/%Y") if annot.date else "?"} + par {author.get_prenomnom() if author else "?"} : + {annot.comment or ""}{del_link} """ ) - info["liste_annotations"] = "\n".join(alist) + info["liste_annotations"] = "\n".join(annotations_list) # fiche admission - has_adm_notes = ( - info["math"] or info["physique"] or info["anglais"] or info["francais"] + infos_admission = _infos_admission(etud, restrict_etud_data) + has_adm_notes = any( + infos_admission[k] for k in ("math", "physique", "anglais", "francais") ) - has_bac_info = ( - info["bac"] - or info["specialite"] - or info["annee_bac"] - or info["rapporteur"] - or info["commentaire"] - or info["classement"] - or info["type_admission"] + has_bac_info = any( + infos_admission[k] + for k in ( + "bac_specialite", + "annee_bac", + "rapporteur", + "commentaire", + "classement", + "type_admission", + "rap", + ) ) if has_bac_info or has_adm_notes: adm_tmpl = """ @@ -411,7 +379,7 @@ def ficheEtud(etudid=None): BacAnnéeRg MathPhysiqueAnglaisFrançais -%(bac)s (%(specialite)s) +%(bac_specialite)s %(annee_bac)s %(classement)s %(math)s%(physique)s%(anglais)s%(francais)s @@ -419,22 +387,22 @@ def ficheEtud(etudid=None): """ adm_tmpl += """ -

      Bac %(bac)s (%(specialite)s) obtenu en %(annee_bac)s
      -
      %(ilycee)s
      """ - if info["type_admission"] or info["classement"]: +
      Bac %(bac_specialite)s obtenu en %(annee_bac)s
      +
      %(info_lycee)s
      """ + if infos_admission["type_admission"] or infos_admission["classement"]: adm_tmpl += """
      """ - if info["type_admission"]: + if infos_admission["type_admission"]: adm_tmpl += """Voie d'admission: %(type_admission)s """ - if info["classement"]: + if infos_admission["classement"]: adm_tmpl += """Rang admission: %(classement)s""" - if info["type_admission"] or info["classement"]: + if infos_admission["type_admission"] or infos_admission["classement"]: adm_tmpl += "
      " - if info["rap"]: + if infos_admission["rap"]: adm_tmpl += """
      %(rap)s
      """ adm_tmpl += """

    """ else: adm_tmpl = "" # pas de boite "info admission" - info["adm_data"] = adm_tmpl % info + info["adm_data"] = adm_tmpl % infos_admission # Fichiers archivés: info["fichiers_archive_htm"] = ( @@ -455,18 +423,16 @@ def ficheEtud(etudid=None): if has_debouche: info[ "debouche_html" - ] = """
    + ] = f"""
    Devenir:
      - %s + {link_add_suivi}
    -
    """ % ( - suivi_readonly, - info["etudid"], - link_add_suivi, - ) +
    """ else: info["debouche_html"] = "" # pas de boite "devenir" # @@ -492,70 +458,92 @@ def ficheEtud(etudid=None): else: info["groupes_row"] = "" info["menus_etud"] = menus_etud(etudid) - if info["boursier"]: + if info["boursier"] and not restrict_etud_data: info["bourse_span"] = """boursier""" else: info["bourse_span"] = "" - # raccordement provisoire pour juillet 2022, avant refonte complète de cette fiche... - # info["but_infos_mkup"] = jury_but_view.infos_fiche_etud_html(etudid) - - # XXX dev - info["but_cursus_mkup"] = "" - if info["sems"]: - last_sem = FormSemestre.query.get_or_404(info["sems"][0]["formsemestre_id"]) - if last_sem.formation.is_apc(): - but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation) - info[ - "but_cursus_mkup" - ] = f""" -
    - {render_template( - "but/cursus_etud.j2", - cursus=but_cursus, - scu=scu, - )} - + # Liens vers compétences BUT + if last_formsemestre and last_formsemestre.formation.is_apc(): + but_cursus = cursus_but.EtudCursusBUT(etud, last_formsemestre.formation) + info[ + "but_cursus_mkup" + ] = f""" +
    + {render_template( + "but/cursus_etud.j2", + cursus=but_cursus, + scu=scu, + )} + - """ +
    + """ + else: + info["but_cursus_mkup"] = "" - tmpl = """ -
    + adresse_template = ( + "" + if restrict_etud_data + else """ + +
    +
    + + + + +
    Adresse : %(domicile)s %(codepostaldomicile)s %(villedomicile)s %(paysdomicile)s + %(modifadresse)s + %(telephones)s +
    +
    + """ + ) + + info_naissance = ( + f"""Né{etud.e} le :{info["info_naissance"]}""" + if info["info_naissance"] + else "" + ) + situation_template = ( + f""" +
    +
    + + + %(groupes_row)s + {info_naissance} +
    Situation :%(situation)s %(bourse_span)s
    + """ + + adresse_template + + """ +
    +
    + """ + ) + + tmpl = ( + """ +

    %(nomprenom)s (%(inscription)s)

    %(etat_civil)s -%(emaillink)s +%(email_link)s
    %(etudfoto)s
    - -
    -
    - - -%(groupes_row)s - -
    Situation :%(situation)s %(bourse_span)s
    Né%(ne)s le :%(info_naissance)s
    - - - -
    - -
    Adresse : %(domicile)s %(codepostaldomicile)s %(villedomicile)s %(paysdomicile)s -%(modifadresse)s -%(telephones)s -
    -
    -
    -
    +""" + + situation_template + + """ %(inscriptions_mkup)s @@ -595,8 +583,9 @@ def ficheEtud(etudid=None):
    """ + ) header = html_sco_header.sco_header( - page_title="Fiche étudiant %(prenom)s %(nom)s" % info, + page_title=f"Fiche étudiant {etud.nomprenom}", cssstyles=[ "libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/jury_but.css", @@ -614,6 +603,92 @@ def ficheEtud(etudid=None): return header + tmpl % info + html_sco_header.sco_footer() +def _format_adresse(adresse: Adresse | None) -> dict: + """{ "telephonestr" : ..., "telephonemobilestr" : ... } (formats html)""" + d = { + "telephonestr": ("Tél.: " + scu.format_telephone(adresse.telephone)) + if (adresse and adresse.telephone) + else "", + "telephonemobilestr": ( + "Mobile: " + scu.format_telephone(adresse.telephonemobile) + ) + if (adresse and adresse.telephonemobile) + else "", + # e-mail: + "email_link": ", ".join( + [ + f"""{m}""" + for m in [adresse.email, adresse.emailperso] + if m + ] + ) + if adresse and (adresse.email or adresse.emailperso) + else "", + "domicile": (adresse.domicile or "") + if adresse + and (adresse.domicile or adresse.codepostaldomicile or adresse.villedomicile) + else "inconnue", + "paysdomicile": f"{sco_etud.format_pays(adresse.paysdomicile)}" + if adresse and adresse.paysdomicile + else "", + } + d["telephones"] = ( + f"
    {d['telephonestr']}    {d['telephonemobilestr']}" + if adresse and (adresse.telephone or adresse.telephonemobile) + else "" + ) + return d + + +def _infos_admission(etud: Identite, restrict_etud_data: bool) -> dict: + """dict with adminission data, restricted or not""" + # info sur rapporteur et son commentaire + rap = "" + if not restrict_etud_data: + if etud.admission.rapporteur or etud.admission.commentaire: + rap = "Note du rapporteur" + if etud.admission.rapporteur: + rap += f" ({etud.admission.rapporteur})" + rap += ": " + if etud.admission.commentaire: + rap += f"{etud.admission.commentaire}" + # nom du lycée + if restrict_etud_data: + info_lycee = "" + elif etud.admission.nomlycee: + info_lycee = "Lycée " + sco_etud.format_lycee(etud.admission.nomlycee) + if etud.admission.villelycee: + info_lycee += f" ({etud.admission.villelycee})" + info_lycee += "
    " + elif etud.admission.codelycee: + info_lycee = sco_etud.format_lycee_from_code(etud.admission.codelycee) + else: + info_lycee = "" + + return { + # infos accessibles à tous: + "bac_specialite": f"{etud.admission.bac or ''}{(' '+(etud.admission.specialite or '')) if etud.admission.specialite else ''}", + "annee_bac": etud.admission.annee_bac or "", + # infos protégées par ViewEtudData: + "info_lycee": info_lycee, + "rapporteur": etud.admission.rapporteur if not restrict_etud_data else "", + "rap": rap, + "commentaire": (etud.admission.commentaire or "") + if not restrict_etud_data + else "", + "classement": (etud.admission.classement or "") + if not restrict_etud_data + else "", + "type_admission": (etud.admission.type_admission or "") + if not restrict_etud_data + else "", + "math": (etud.admission.math or "") if not restrict_etud_data else "", + "physique": (etud.admission.physique or "") if not restrict_etud_data else "", + "anglais": (etud.admission.anglais or "") if not restrict_etud_data else "", + "francais": (etud.admission.francais or "") if not restrict_etud_data else "", + } + + def menus_etud(etudid): """Menu etudiant (operations sur l'etudiant)""" authuser = current_user @@ -623,7 +698,7 @@ def menus_etud(etudid): menuEtud = [ { "title": etud["nomprenom"], - "endpoint": "scolar.ficheEtud", + "endpoint": "scolar.fiche_etud", "args": {"etudid": etud["etudid"]}, "enabled": True, "helpmsg": "Fiche étudiant", @@ -671,36 +746,33 @@ def etud_info_html(etudid, with_photo="1", debug=False): """ formsemestre_id = sco_formsemestre_status.retreive_formsemestre_from_request() with_photo = int(with_photo) - etuds = sco_etud.get_etud_info(filled=True) - if etuds: - etud = etuds[0] - else: - abort(404, "etudiant inconnu") - photo_html = sco_photos.etud_photo_html(etud, title="fiche de " + etud["nom"]) - # experimental: may be too slow to be here - code_cursus, _ = sco_report.get_code_cursus_etud(etud, prefix="S", separator=", ") + etud = Identite.get_etud(etudid) - bac = sco_bac.Baccalaureat(etud["bac"], etud["specialite"]) + photo_html = etud.photo_html(etud, title="fiche de " + etud.nomprenom) + code_cursus, _ = sco_report.get_code_cursus_etud( + etud, formsemestres=etud.get_formsemestres(), prefix="S", separator=", " + ) + bac = sco_bac.Baccalaureat(etud.admission.bac, etud.admission.specialite) bac_abbrev = bac.abbrev() H = f"""
    Bac: {bac_abbrev}
    {code_cursus}
    """ # Informations sur l'etudiant dans le semestre courant: - sem = None + formsemestre = None if formsemestre_id: # un semestre est spécifié par la page - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - elif etud["cursem"]: # le semestre "en cours" pour l'étudiant - sem = etud["cursem"] - if sem: - groups = sco_groups.get_etud_groups(etudid, formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + elif inscription_courante: # le semestre "en cours" pour l'étudiant + formsemestre = inscription_courante.formsemestre + if formsemestre: + groups = sco_groups.get_etud_groups(etudid, formsemestre.id) grc = sco_groups.listgroups_abbrev(groups) - H += f"""
    En S{sem["semestre_id"]}: {grc}
    """ + H += f"""
    En S{formsemestre.semestre_id}: {grc}
    """ H += "
    " # fin partie gauche (eid_left) if with_photo: H += '' + photo_html + "" diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index d371833c..a9c437b8 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -55,6 +55,7 @@ _SCO_PERMISSIONS = ( "Exporter les données de l'application relations entreprises", ), (1 << 29, "UsersChangeCASId", "Paramétrer l'id CAS"), + (1 << 30, "ViewEtudData", "Accéder aux données personnelles des étudiants"), # # XXX inutilisée ? (1 << 40, "EtudChangePhoto", "Modifier la photo d'un étudiant"), # Permissions du module Assiduité) diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index cfb4a2ed..f4038962 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -187,7 +187,7 @@ def formsemestre_poursuite_report(formsemestre_id, fmt="html"): ids = [] for etud in etuds: fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url diff --git a/app/scodoc/sco_pv_forms.py b/app/scodoc/sco_pv_forms.py index 54722be1..ce5e624c 100644 --- a/app/scodoc/sco_pv_forms.py +++ b/app/scodoc/sco_pv_forms.py @@ -144,7 +144,7 @@ def pvjury_table( "code_nip": e["identite"]["code_nip"], "nomprenom": e["identite"]["nomprenom"], "_nomprenom_target": url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["identite"]["etudid"], ), @@ -351,7 +351,7 @@ def formsemestre_pvjury_pdf(formsemestre_id, group_ids: list[int] = None, etudid # PV pour ce seul étudiant: etud = Identite.get_etud(etudid) etuddescr = f"""{etud.nomprenom}""" etudids = [etudid] else: diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index d532ea2c..769c202f 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -1017,34 +1017,60 @@ EXP_LIC = re.compile(r"licence", re.I) EXP_LPRO = re.compile(r"professionnelle", re.I) -def _codesem(sem, short=True, prefix=""): +def _code_sem( + semestre_id: int, titre: str, mois_debut: int, short=True, prefix="" +) -> str: "code semestre: S1 ou S1d" - idx = sem["semestre_id"] + idx = semestre_id # semestre décalé ? # les semestres pairs normaux commencent entre janvier et mars # les impairs normaux entre aout et decembre d = "" - if idx and idx > 0 and sem["date_debut"]: - mois_debut = int(sem["date_debut"].split("/")[1]) + if idx > 0: if (idx % 2 and mois_debut < 3) or (idx % 2 == 0 and mois_debut >= 8): d = "d" if idx == -1: if short: idx = "Autre " else: - idx = sem["titre"] + " " + idx = titre + " " idx = EXP_LPRO.sub("pro.", idx) idx = EXP_LIC.sub("Lic.", idx) prefix = "" # indique titre au lieu de Sn - return "%s%s%s" % (prefix, idx, d) + return prefix + str(idx) + d -def get_code_cursus_etud(etud, prefix="", separator=""): +def _code_sem_formsemestre(formsemestre: FormSemestre, short=True, prefix="") -> str: + "code semestre: S1 ou S1d" + titre = formsemestre.titre + mois_debut = formsemestre.date_debut.month + semestre_id = formsemestre.semestre_id + return _code_sem(semestre_id, titre, mois_debut, short=short, prefix=prefix) + + +def _code_sem_dict(sem, short=True, prefix="") -> str: + "code semestre: S1 ou S1d, à parit d'un dict (sem ScoDoc 7)" + titre = sem["titre"] + mois_debut = int(sem["date_debut"].split("/")[1]) if sem["date_debut"] else 0 + semestre_id = sem["semestre_id"] + return _code_sem(semestre_id, titre, mois_debut, short=short, prefix=prefix) + + +def get_code_cursus_etud( + etudid: int, + sems: list[dict] = None, + formsemestres: list[FormSemestre] | None = None, + prefix="", + separator="", +) -> tuple[str, dict]: """calcule un code de cursus (parcours) pour un etudiant exemples: 1234A pour un etudiant ayant effectué S1, S2, S3, S4 puis diplome 12D pour un étudiant en S1, S2 puis démission en S2 12R pour un etudiant en S1, S2 réorienté en fin de S2 + + On peut passer soir la liste des semestres dict (anciennes fonctions ScoDoc7) + soit la liste des FormSemestre. Construit aussi un dict: { semestre_id : decision_jury | None } """ # Nota: approche plus moderne: @@ -1054,31 +1080,37 @@ def get_code_cursus_etud(etud, prefix="", separator=""): # p = [] decisions_jury = {} - # élimine les semestres spéciaux hors cursus (LP en 1 sem., ...) - sems = [s for s in etud["sems"] if s["semestre_id"] >= 0] - i = len(sems) - 1 - while i >= 0: - s = sems[i] # 'sems' est a l'envers, du plus recent au plus ancien - s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) - p.append(_codesem(s, prefix=prefix)) + if formsemestres is None: + formsemestres = [ + FormSemestre.query.get_or_404(s["formsemestre_id"]) for s in (sems or []) + ] + + # élimine les semestres spéciaux hors cursus (LP en 1 sem., ...) + formsemestres = [s for s in formsemestres if s.semestre_id >= 0] + i = len(formsemestres) - 1 + while i >= 0: + # 'sems' est a l'envers, du plus recent au plus ancien + formsemestre = formsemestres[i] + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + p.append(_code_sem_formsemestre(formsemestre, prefix=prefix)) # code decisions jury de chaque semestre: - if nt.get_etud_etat(etud["etudid"]) == "D": - decisions_jury[s["semestre_id"]] = "DEM" + if nt.get_etud_etat(etudid) == "D": + decisions_jury[formsemestre.semestre_id] = "DEM" else: - dec = nt.get_etud_decision_sem(etud["etudid"]) + dec = nt.get_etud_decision_sem(etudid) if not dec: - decisions_jury[s["semestre_id"]] = "" + decisions_jury[formsemestre.semestre_id] = "" else: - decisions_jury[s["semestre_id"]] = dec["code"] + decisions_jury[formsemestre.semestre_id] = dec["code"] # code etat dans le code_cursus sur dernier semestre seulement if i == 0: # Démission - if nt.get_etud_etat(etud["etudid"]) == "D": + if nt.get_etud_etat(etudid) == "D": p.append(":D") else: - dec = nt.get_etud_decision_sem(etud["etudid"]) + dec = nt.get_etud_decision_sem(etudid) if dec and dec["code"] in codes_cursus.CODES_SEM_REO: p.append(":R") if ( @@ -1176,14 +1208,16 @@ def table_suivi_cursus(formsemestre_id, only_primo=False, grouped_parcours=True) ) = tsp_etud_list(formsemestre_id, only_primo=only_primo) codes_etuds = collections.defaultdict(list) for etud in etuds: - etud["code_cursus"], etud["decisions_jury"] = get_code_cursus_etud(etud) + etud["code_cursus"], etud["decisions_jury"] = get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) codes_etuds[etud["code_cursus"]].append(etud) fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + etud["_nom_td_attrs"] = f'''id="{etud['etudid']}" class="etudinfo"''' titles = { "parcours": "Code cursus", @@ -1461,7 +1495,7 @@ def graph_cursus( else: modalite = "" label = "%s%s\\n%d/%s - %d/%s\\n%d" % ( - _codesem(s, short=False, prefix="S"), + _code_sem_dict(s, short=False, prefix="S"), modalite, s["mois_debut_ord"], s["annee_debut"][2:], diff --git a/app/scodoc/sco_roles_default.py b/app/scodoc/sco_roles_default.py index 75cbdedd..a727a94b 100644 --- a/app/scodoc/sco_roles_default.py +++ b/app/scodoc/sco_roles_default.py @@ -13,8 +13,9 @@ SCO_ROLES_DEFAULTS = { p.EnsView, p.EtudAddAnnotations, p.Observateur, - p.UsersView, p.ScoView, + p.ViewEtudData, + p.UsersView, ), "Secr": ( p.AbsAddBillet, @@ -23,8 +24,9 @@ SCO_ROLES_DEFAULTS = { p.EtudAddAnnotations, p.EtudChangeAdr, p.Observateur, - p.UsersView, p.ScoView, + p.UsersView, + p.ViewEtudData, ), # Admin est le chef du département, pas le "super admin" # on doit donc lister toutes ses permissions: @@ -44,9 +46,10 @@ SCO_ROLES_DEFAULTS = { p.EtudInscrit, p.EditFormSemestre, p.Observateur, + p.ScoView, p.UsersAdmin, p.UsersView, - p.ScoView, + p.ViewEtudData, ), # Rôles pour l'application relations entreprises # ObservateurEntreprise est un observateur de l'application entreprise @@ -57,7 +60,8 @@ SCO_ROLES_DEFAULTS = { p.RelationsEntrepEdit, p.RelationsEntrepViewCorrs, ), - # AdminEntreprise est un admin de l'application entreprise (toutes les actions possibles de l'application) + # AdminEntreprise est un admin de l'application entreprise + # (toutes les actions possibles de l'application) "AdminEntreprise": ( p.RelationsEntrepView, p.RelationsEntrepEdit, diff --git a/app/scodoc/sco_trombino.py b/app/scodoc/sco_trombino.py index c197cefd..a77e6ec3 100644 --- a/app/scodoc/sco_trombino.py +++ b/app/scodoc/sco_trombino.py @@ -156,7 +156,7 @@ def trombino_html(groups_infos): '%s' % ( url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=t["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=t["etudid"] ), foto, ) diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index a6d5d8b9..97bfe47e 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -431,7 +431,7 @@ APO_MISSING_CODE_STR = "----" # shown in HTML pages in place of missing code Ap EDIT_NB_ETAPES = 6 # Nombre max de codes étapes / semestre presentés dans l'UI IT_SITUATION_MISSING_STR = ( - "____" # shown on ficheEtud (devenir) in place of empty situation + "____" # shown on fiche_etud (devenir) in place of empty situation ) RANG_ATTENTE_STR = "(attente)" # rang affiché sur bulletins quand notes en attente @@ -1285,6 +1285,27 @@ def format_prenom(s): return " ".join(r) +def format_telephone(n: str | None) -> str: + "Format a phone number for display" + if n is None: + return "" + if len(n) < 7: + return n + n = n.replace(" ", "").replace(".", "") + i = 0 + r = "" + j = len(n) - 1 + while j >= 0: + r = n[j] + r + if i % 2 == 1 and j != 0: + r = " " + r + i += 1 + j -= 1 + if len(r) == 13 and r[0] != "0": + r = "0" + r + return r + + # def timedate_human_repr(): "representation du temps courant pour utilisateur" diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index ac2c691a..c6d62c49 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -724,7 +724,7 @@ div.scoinfos { /* ----- fiches etudiants ------ */ -div.ficheEtud { +div.fiche_etud { background-color: #f5edc8; /* rgb(255,240,128); */ border: 1px solid gray; @@ -739,7 +739,7 @@ div.menus_etud { margin-top: 1px; } -div.ficheEtud h2 { +div.fiche_etud h2 { padding-top: 10px; } @@ -925,7 +925,7 @@ td.fichetitre2 { vertical-align: top; } -.ficheEtud span.boursier { +.fiche_etud span.boursier { background-color: red; color: white; margin-left: 12px; @@ -963,6 +963,7 @@ div.section_but { div.section_but > div.link_validation_rcues { align-self: center; + text-align: center; } .ficheannotations { diff --git a/app/templates/bul_head.j2 b/app/templates/bul_head.j2 index 8c725f01..0635c12c 100644 --- a/app/templates/bul_head.j2 +++ b/app/templates/bul_head.j2 @@ -7,7 +7,7 @@ {% if not is_apc %}

    {{etud.nomprenom}}

    {% endif %}
    @@ -81,7 +81,7 @@
    {% if not is_apc %} {% endif %} diff --git a/app/templates/entreprises/fiche_entreprise.j2 b/app/templates/entreprises/fiche_entreprise.j2 index e2c0da71..08bf7ff9 100644 --- a/app/templates/entreprises/fiche_entreprise.j2 +++ b/app/templates/entreprises/fiche_entreprise.j2 @@ -183,7 +183,7 @@ {{ (stage_apprentissage.date_fin-stage_apprentissage.date_debut).days//7 }} semaines {{ stage_apprentissage.type_offre }} {{ + href="{{ url_for('scolar.fiche_etud', scodoc_dept=etudiant.dept_id|get_dept_acronym, etudid=stage_apprentissage.etudid) }}">{{ etudiant.nom|format_nom }} {{ etudiant.prenom|format_prenom }} {% if stage_apprentissage.formation_text %}{{ stage_apprentissage.formation_text }}{% endif %} {{ stage_apprentissage.notes }} diff --git a/app/templates/scolar/partition_editor.j2 b/app/templates/scolar/partition_editor.j2 index ef0e5345..6d5bcf72 100644 --- a/app/templates/scolar/partition_editor.j2 +++ b/app/templates/scolar/partition_editor.j2 @@ -165,7 +165,7 @@ span.calendarEdit { etudiants.forEach(etudiant => { output += `
    - + ${(() => { let output = "
    "; arrayPartitions.forEach((partition) => { diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 865208c1..884931ad 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -51,7 +51,7 @@
    {% if sco.etud %}

    + 'scolar.fiche_etud', scodoc_dept=g.scodoc_dept, etudid=sco.etud.id )}}" class="sidebar"> {{sco.etud.nomprenom}}

    Absences diff --git a/app/views/absences.py b/app/views/absences.py index 52082b2f..75e61f67 100644 --- a/app/views/absences.py +++ b/app/views/absences.py @@ -358,7 +358,7 @@ def process_billet_absence_form(billet_id: int): page_title=f"Traitement billet d'absence de {etud.nomprenom}", ), f"""

    Traitement du billet {billet.id} : {etud.nomprenom}

    """, ] diff --git a/app/views/notes.py b/app/views/notes.py index 08086b44..ae977682 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -1193,7 +1193,7 @@ def view_module_abs(moduleimpl_id, fmt="html"): "nojust": nb_abs - nb_abs_just, "total": nb_abs, "_nomprenom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ), } ) @@ -1492,7 +1492,7 @@ def formsemestre_desinscription(etudid, formsemestre_id, dialog_confirmed=False) flash("Étudiant désinscrit") return redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -2371,13 +2371,13 @@ def formsemestre_validation_but(
    {etud.nomprenom}
    Impossible de statuer sur cet étudiant: il est démissionnaire ou défaillant (voir sa fiche)
    @@ -2450,7 +2450,7 @@ def formsemestre_validation_but(
    {etud.nomprenom}
    @@ -2725,7 +2725,7 @@ def formsemestre_validation_suppress_etud( etud = Identite.get_etud(etudid) if formsemestre.formation.is_apc(): next_url = url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, ) @@ -2915,7 +2915,7 @@ def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int): flash("Décisions de jury effacées") return redirect( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id, ) @@ -2931,7 +2931,7 @@ def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int): "jury/erase_decisions_annee_formation.j2", annee=annee, cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ), etud=etud, formation=formation, diff --git a/app/views/scolar.py b/app/views/scolar.py index 7be21ee0..d842dc1b 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -328,7 +328,7 @@ def showEtudLog(etudid, fmt="html"): filename="log_" + scu.make_filename(etud["nomprenom"]), html_next_section=f""" """, preferences=sco_preferences.SemPreferences(), @@ -625,7 +625,7 @@ def etud_info(etudid=None, fmt="xml"): # -------------------------- FICHE ETUDIANT -------------------------- -sco_publish("/ficheEtud", sco_page_etud.ficheEtud, Permission.ScoView) +sco_publish("/fiche_etud", sco_page_etud.fiche_etud, Permission.ScoView) sco_publish( "/etud_upload_file_form", @@ -720,7 +720,7 @@ def doAddAnnotation(etudid, comment): ) logdb(cnx, method="addAnnotation", etudid=etudid) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -745,7 +745,7 @@ def doSuppressAnnotation(etudid, annotation_id): flash("Annotation supprimée") return flask.redirect( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, ) @@ -809,7 +809,7 @@ def form_change_coordonnees(etudid): initvalues=adr, submitlabel="Valider le formulaire", ) - dest_url = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + dest_url = url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) if tf[0] == 0: return "\n".join(H) + tf[1] + html_sco_header.sco_footer() elif tf[0] == -1: @@ -1009,7 +1009,7 @@ def etud_photo_orig_page(etudid=None): html_sco_header.sco_header(page_title=etud["nomprenom"]), "

    %s

    " % etud["nomprenom"], '", html_sco_header.sco_footer(), @@ -1053,7 +1053,7 @@ def form_change_photo(etudid=None): submitlabel="Valider", cancelbutton="Annuler", ) - dest_url = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) + dest_url = url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id) if tf[0] == 0: return ( "\n".join(H) @@ -1092,7 +1092,7 @@ def form_suppress_photo(etudid=None, dialog_confirmed=False): f"

    Confirmer la suppression de la photo de {etud.nom_disp()} ?

    ", dest_url="", cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ), parameters={"etudid": etud.id}, ) @@ -1100,7 +1100,7 @@ def form_suppress_photo(etudid=None, dialog_confirmed=False): sco_photos.suppress_photo(etud) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id) ) @@ -1229,7 +1229,7 @@ def _do_dem_or_def_etud( ) if redirect: return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -1301,7 +1301,7 @@ def _do_cancel_dem_or_def( f"

    Confirmer l'annulation de la {operation_name} ?

    ", dest_url="", cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, ) @@ -1325,7 +1325,7 @@ def _do_cancel_dem_or_def( flash(f"{operation_name} annulée.") return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -1784,7 +1784,7 @@ def _etudident_create_or_edit_form(edit): sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) # return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -1802,7 +1802,7 @@ def etud_copy_in_other_dept(etudid: int): action = request.form.get("action") if action == "cancel": return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id) ) try: formsemestre_id = int(request.form.get("formsemestre_id")) @@ -1833,7 +1833,7 @@ def etud_copy_in_other_dept(etudid: int): # Attention, ce redirect change de département ! return flask.redirect( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=formsemestre.departement.acronym, etudid=new_etud.id, ) @@ -1881,12 +1881,12 @@ def etudident_delete(etudid: int = -1, dialog_confirmed=False): d'un semestre ! (pour cela, passez par sa fiche, menu associé au semestre)

    Vérifier la fiche de {etud.nomprenom}

    """, dest_url="", cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), OK="Supprimer définitivement cet étudiant", parameters={"etudid": etudid}, @@ -2018,7 +2018,7 @@ def check_group_apogee(group_id, etat=None, fix=False, fixmail=False): H.append( '%s%s%s%s%s%s' % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), nom, nom_usuel, prenom, diff --git a/migrations/versions/3fa988ff8970_config_permission_viewetuddata.py b/migrations/versions/3fa988ff8970_config_permission_viewetuddata.py new file mode 100644 index 00000000..b4f6d62d --- /dev/null +++ b/migrations/versions/3fa988ff8970_config_permission_viewetuddata.py @@ -0,0 +1,44 @@ +"""config nouvelle permission ViewEtudData: donne aux rôles Ens, Secr, Admin + +Revision ID: 3fa988ff8970 +Revises: b4859c04205f +Create Date: 2024-01-20 13:59:31.491442 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3fa988ff8970" +down_revision = "b4859c04205f" +branch_labels = None +depends_on = None + + +def upgrade(): + # Donne la permission ViewEtudData aux rôles Admin, Ens, Secr + # cette permission est 1<<30 + op.execute( + "UPDATE role SET permissions = permissions | (1<<30) where role.name = 'Admin';" + ) + op.execute( + "UPDATE role SET permissions = permissions | (1<<30) where role.name = 'Ens';" + ) + op.execute( + "UPDATE role SET permissions = permissions | (1<<30) where role.name = 'Secr';" + ) + + +def downgrade(): + # retire la permission ViewEtudData aux rôles Admin, Ens, Secr + # cette permission est 1<<30 + op.execute( + "UPDATE role SET permissions = permissions & ~(1<<30) where role.name = 'Admin';" + ) + op.execute( + "UPDATE role SET permissions = permissions & ~(1<<30) where role.name = 'Ens';" + ) + op.execute( + "UPDATE role SET permissions = permissions & ~(1<<30) where role.name = 'Secr';" + ) diff --git a/tests/unit/test_etudiants.py b/tests/unit/test_etudiants.py index 47346ca8..f2d83d2e 100644 --- a/tests/unit/test_etudiants.py +++ b/tests/unit/test_etudiants.py @@ -357,16 +357,11 @@ def test_import_etuds_xlsx(test_client): "civilite_etat_civil_str": "Mme", "nom_disp": "NOM_USUEL10 (NOM10)", "ne": "(e)", - "email_default": "", "inscription": "ancien", "situation": "ancien élève", "inscriptionstr": "ancien", "inscription_formsemestre_id": None, "etatincursem": "?", - "ilycee": "", - "rap": "", - "telephonestr": "", - "telephonemobilestr": "", }, ) # Test de search_etud_in_dept From 238fbe887c5b177537c8111bc36ee0b8106a341e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 17:37:24 +0100 Subject: [PATCH 20/55] RGPD: ViewEtudData. Implements #842 --- app/api/etudiants.py | 22 +++-- app/api/formsemestres.py | 5 +- app/models/etudiants.py | 14 ++- app/scodoc/sco_archives_etud.py | 2 +- app/scodoc/sco_formsemestre_status.py | 10 +- app/scodoc/sco_groups_view.py | 136 ++++++++++++++++---------- app/scodoc/sco_page_etud.py | 29 ++++-- app/scodoc/sco_permissions.py | 6 +- app/static/css/scodoc.css | 5 + app/views/notes.py | 2 +- app/views/scolar.py | 14 ++- tests/api/test_api_etudiants.py | 17 ++-- tests/api/tools_test_api.py | 8 +- 13 files changed, 174 insertions(+), 96 deletions(-) diff --git a/app/api/etudiants.py b/app/api/etudiants.py index a508ee3b..d66c648d 100755 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -104,7 +104,8 @@ def etudiants_courants(long=False): or_(Departement.acronym == acronym for acronym in allowed_depts) ) if long: - data = [etud.to_dict_api() for etud in etuds] + restrict = not current_user.has_permission(Permission.ViewEtudData) + data = [etud.to_dict_api(restrict=restrict) for etud in etuds] else: data = [etud.to_dict_short() for etud in etuds] return data @@ -138,8 +139,8 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None): 404, message="étudiant inconnu", ) - - return etud.to_dict_api() + restrict = not current_user.has_permission(Permission.ViewEtudData) + return etud.to_dict_api(restrict=restrict) @bp.route("/etudiant/etudid//photo") @@ -251,7 +252,8 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None): query = query.join(Departement).filter( or_(Departement.acronym == acronym for acronym in allowed_depts) ) - return [etud.to_dict_api() for etud in query] + restrict = not current_user.has_permission(Permission.ViewEtudData) + return [etud.to_dict_api(restrict=restrict) for etud in query] @bp.route("/etudiants/name/") @@ -278,7 +280,11 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32): ) etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit) # Note: on raffine le tri pour les caractères spéciaux et nom usuel ici: - return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))] + restrict = not current_user.has_permission(Permission.ViewEtudData) + return [ + etud.to_dict_api(restrict=restrict) + for etud in sorted(etuds, key=attrgetter("sort_key")) + ] @bp.route("/etudiant/etudid//formsemestres") @@ -543,7 +549,8 @@ def etudiant_create(force=False): # Note: je ne comprends pas pourquoi un refresh est nécessaire ici # sans ce refresh, etud.__dict__ est incomplet (pas de 'nom'). db.session.refresh(etud) - r = etud.to_dict_api() + + r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer return r @@ -590,5 +597,6 @@ def etudiant_edit( # Note: je ne comprends pas pourquoi un refresh est nécessaire ici # sans ce refresh, etud.__dict__ est incomplet (pas de 'nom'). db.session.refresh(etud) - r = etud.to_dict_api() + restrict = not current_user.has_permission(Permission.ViewEtudData) + r = etud.to_dict_api(restrict=restrict) return r diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 6fc38aea..662ddd4d 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -11,7 +11,7 @@ from operator import attrgetter, itemgetter from flask import g, make_response, request from flask_json import as_json -from flask_login import login_required +from flask_login import current_user, login_required import app from app import db @@ -360,7 +360,8 @@ def formsemestre_etudiants( inscriptions = formsemestre.inscriptions if long: - etuds = [ins.etud.to_dict_api() for ins in inscriptions] + restrict = not current_user.has_permission(Permission.ViewEtudData) + etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions] else: etuds = [ins.etud.to_dict_short() for ins in inscriptions] # Ajout des groupes de chaque étudiants diff --git a/app/models/etudiants.py b/app/models/etudiants.py index bc2d0560..a03058e9 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -421,7 +421,7 @@ class Identite(models.ScoDocModel): return args_dict def to_dict_short(self) -> dict: - """Les champs essentiels""" + """Les champs essentiels (aucune donnée perso protégée)""" return { "id": self.id, "civilite": self.civilite, @@ -494,16 +494,22 @@ class Identite(models.ScoDocModel): d["id"] = self.id # a été écrasé par l'id de adresse return d - def to_dict_api(self) -> dict: - """Représentation dictionnaire pour export API, avec adresses et admission.""" + def to_dict_api(self, restrict=False) -> dict: + """Représentation dictionnaire pour export API, avec adresses et admission. + Si restrict, supprime les infos "personnelles" (boursier) + """ e = dict(self.__dict__) e.pop("_sa_instance_state", None) admission = self.admission e["admission"] = admission.to_dict() if admission is not None else None - e["adresses"] = [adr.to_dict() for adr in self.adresses] + e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses] e["dept_acronym"] = self.departement.acronym e.pop("departement", None) e["sort_key"] = self.sort_key + if restrict: + # Met à None les attributs protégés: + for attr in self.protected_attrs: + e[attr] = None return e def inscriptions(self) -> list["FormSemestreInscription"]: diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 42fddde2..6f174f15 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -62,7 +62,7 @@ def can_edit_etud_archive(authuser): def etud_list_archives_html(etud: Identite): - """HTML snippet listing archives""" + """HTML snippet listing archives.""" can_edit = can_edit_etud_archive(current_user) etud_archive_id = etud.id L = [] diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 6aa773a7..fa63d54a 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -51,13 +51,14 @@ from app.models import ( NotesNotes, ) from app.scodoc.codes_cursus import UE_SPORT -import app.scodoc.sco_utils as scu -from app.scodoc.sco_utils import ModuleType -from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( ScoValueError, ScoInvalidIdType, ) +from app.scodoc.sco_permissions import Permission +import app.scodoc.sco_utils as scu +from app.scodoc.sco_utils import ModuleType + from app.scodoc import html_sco_header from app.scodoc import htmlutils from app.scodoc import sco_archives_formsemestre @@ -109,7 +110,7 @@ def _build_menu_stats(formsemestre_id): "title": "Lycées d'origine", "endpoint": "notes.formsemestre_etuds_lycees", "args": {"formsemestre_id": formsemestre_id}, - "enabled": True, + "enabled": current_user.has_permission(Permission.ViewEtudData), }, { "title": 'Table "poursuite"', @@ -336,6 +337,7 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: formsemestre_id, fix_if_missing=True ), }, + "enabled": current_user.has_permission(Permission.ViewEtudData), }, { "title": "Vérifier inscriptions multiples", diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index c02a3e88..a404c7fa 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -52,7 +52,7 @@ from app.scodoc import sco_preferences from app.scodoc import sco_etud from app.scodoc.sco_etud import etud_sort_key from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_exceptions import ScoValueError, ScoPermissionDenied from app.scodoc.sco_permissions import Permission JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ @@ -118,6 +118,16 @@ def groups_view( init_qtip=True, ) } +
    {form_groups_choice(groups_infos, submit_on_change=True)} @@ -474,15 +484,12 @@ def groups_table( """ from app.scodoc import sco_report - # log( - # "enter groups_table %s: %s" - # % (groups_infos.members[0]["nom"], groups_infos.members[0].get("etape", "-")) - # ) + can_view_etud_data = int(current_user.has_permission(Permission.ViewEtudData)) with_codes = int(with_codes) - with_paiement = int(with_paiement) - with_archives = int(with_archives) - with_annotations = int(with_annotations) - with_bourse = int(with_bourse) + with_paiement = int(with_paiement) and can_view_etud_data + with_archives = int(with_archives) and can_view_etud_data + with_annotations = int(with_annotations) and can_view_etud_data + with_bourse = int(with_bourse) and can_view_etud_data base_url_np = groups_infos.base_url + f"&with_codes={with_codes}" base_url = ( @@ -527,7 +534,8 @@ def groups_table( if fmt != "html": # ne mentionne l'état que en Excel (style en html) columns_ids.append("etat") columns_ids.append("email") - columns_ids.append("emailperso") + if can_view_etud_data: + columns_ids.append("emailperso") if fmt == "moodlecsv": columns_ids = ["email", "semestre_groupe"] @@ -616,7 +624,7 @@ def groups_table( + "+".join(sorted(moodle_groupenames)) ) else: - filename = "etudiants_%s" % groups_infos.groups_filename + filename = f"etudiants_{groups_infos.groups_filename}" prefs = sco_preferences.SemPreferences(groups_infos.formsemestre_id) tab = GenTable( @@ -664,28 +672,33 @@ def groups_table( """ ] if groups_infos.members: - Of = [] + menu_options = [] options = { - "with_paiement": "Paiement inscription", - "with_archives": "Fichiers archivés", - "with_annotations": "Annotations", - "with_codes": "Codes", - "with_bourse": "Statut boursier", + "with_codes": "Affiche codes", } - for option in options: + if can_view_etud_data: + options.update( + { + "with_paiement": "Paiement inscription", + "with_archives": "Fichiers archivés", + "with_annotations": "Annotations", + "with_bourse": "Statut boursier", + } + ) + for option, label in options.items(): if locals().get(option, False): selected = "selected" else: selected = "" - Of.append( - """""" - % (option, selected, options[option]) + menu_options.append( + f"""""" ) H.extend( [ - """""", + "\n".join(menu_options), """ """, + """accès aux données personnelles interdit""" + if not can_view_etud_data + else "", ] ) H.append("
    ") @@ -708,41 +724,45 @@ def groups_table( H.extend( [ tab.html(), - "", ] ) @@ -901,14 +926,19 @@ def tab_absences_html(groups_infos, etat=None): """ ) # Lien pour ajout fichiers étudiants - if authuser.has_permission(Permission.EtudAddAnnotations): + text = "Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)" + if authuser.has_permission( + Permission.EtudAddAnnotations + ) and authuser.has_permission(Permission.ViewEtudData): H.append( f"""
  • Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)
  • """ + )}">{text}""" ) + else: + H.append(f"""
  • {text}
  • """) H.append("
    ") return "".join(H) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 1bdb148c..d34a933f 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -198,7 +198,9 @@ def fiche_etud(etudid=None): info["etudfoto"] = etud.photo_html() # Champ dépendant des permissions: - if current_user.has_permission(Permission.EtudChangeAdr): + if current_user.has_permission( + Permission.EtudChangeAdr + ) and current_user.has_permission(Permission.ViewEtudData): info[ "modifadresse" ] = f"""Fichiers associés' - + sco_archives_etud.etud_list_archives_html(etud) + "" + if restrict_etud_data + else ( + '
    Fichiers associés
    ' + + sco_archives_etud.etud_list_archives_html(etud) + ) ) # Devenir de l'étudiant: @@ -713,7 +719,8 @@ def menus_etud(etudid): "title": "Changer les données identité/admission", "endpoint": "scolar.etudident_edit_form", "args": {"etudid": etud["etudid"]}, - "enabled": authuser.has_permission(Permission.EtudInscrit), + "enabled": authuser.has_permission(Permission.EtudInscrit) + and authuser.has_permission(Permission.ViewEtudData), }, { "title": "Copier dans un autre département...", @@ -748,7 +755,7 @@ def etud_info_html(etudid, with_photo="1", debug=False): with_photo = int(with_photo) etud = Identite.get_etud(etudid) - photo_html = etud.photo_html(etud, title="fiche de " + etud.nomprenom) + photo_html = etud.photo_html(title="fiche de " + etud.nomprenom) code_cursus, _ = sco_report.get_code_cursus_etud( etud, formsemestres=etud.get_formsemestres(), prefix="S", separator=", " ) @@ -758,17 +765,21 @@ def etud_info_html(etudid, with_photo="1", debug=False):
    + }">{etud.nomprenom}
    Bac: {bac_abbrev}
    {code_cursus}
    """ # Informations sur l'etudiant dans le semestre courant: - formsemestre = None if formsemestre_id: # un semestre est spécifié par la page formsemestre = FormSemestre.get_formsemestre(formsemestre_id) - elif inscription_courante: # le semestre "en cours" pour l'étudiant - formsemestre = inscription_courante.formsemestre + else: + # le semestre "en cours" pour l'étudiant + inscription_courante = etud.inscription_courante() + formsemestre = ( + inscription_courante.formsemestre if inscription_courante else None + ) + if formsemestre: groups = sco_groups.get_etud_groups(etudid, formsemestre.id) grc = sco_groups.listgroups_abbrev(groups) diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index a9c437b8..bf871252 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -37,7 +37,11 @@ _SCO_PERMISSIONS = ( # aussi pour demissions, diplomes: (1 << 17, "EtudInscrit", "Inscrire des étudiants"), # aussi pour archives: - (1 << 18, "EtudAddAnnotations", "Éditer les annotations"), + ( + 1 << 18, + "EtudAddAnnotations", + "Éditer les annotations (et fichiers) sur étudiants", + ), # inutilisée (1 << 19, "ScoEntrepriseView", "Voir la section 'entreprises'"), # inutilisée (1 << 20, "EntrepriseChange", "Modifier les entreprises"), # XXX inutilisée ? (1 << 21, "EditPVJury", "Éditer les PV de jury"), diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index c6d62c49..948554f2 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -172,6 +172,11 @@ form#group_selector { margin-bottom: 3px; } +/* Text lien ou itms ,non autorisés pour l'utilisateur courant */ +.unauthorized { + color: grey; +} + /* ----- bandeau haut ------ */ span.bandeaugtr { width: 100%; diff --git a/app/views/notes.py b/app/views/notes.py index ae977682..a6e73ac3 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -3230,7 +3230,7 @@ sco_publish( sco_publish( "/formsemestre_etuds_lycees", sco_lycee.formsemestre_etuds_lycees, - Permission.ScoView, + Permission.ViewEtudData, ) sco_publish( "/scodoc_table_etuds_lycees", diff --git a/app/views/scolar.py b/app/views/scolar.py index d842dc1b..7e1837ce 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -442,7 +442,7 @@ sco_publish( sco_publish( "/groups_export_annotations", sco_groups_exports.groups_export_annotations, - Permission.ScoView, + Permission.ViewEtudData, ) @@ -630,27 +630,27 @@ sco_publish("/fiche_etud", sco_page_etud.fiche_etud, Permission.ScoView) sco_publish( "/etud_upload_file_form", sco_archives_etud.etud_upload_file_form, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) sco_publish( "/etud_delete_archive", sco_archives_etud.etud_delete_archive, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) sco_publish( "/etud_get_archived_file", sco_archives_etud.etud_get_archived_file, - Permission.ScoView, + Permission.ViewEtudData, ) sco_publish( "/etudarchive_import_files_form", sco_archives_etud.etudarchive_import_files_form, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) @@ -758,6 +758,8 @@ def doSuppressAnnotation(etudid, annotation_id): @scodoc7func def form_change_coordonnees(etudid): "edit coordonnees etudiant" + if not current_user.has_permission(Permission.ViewEtudData): + raise ScoPermissionDenied() etud = Identite.get_etud(etudid) cnx = ndb.GetDBConnexion() adrs = sco_etud.adresse_list(cnx, {"etudid": etudid}) @@ -1344,6 +1346,8 @@ def etudident_create_form(): @scodoc7func def etudident_edit_form(): "formulaire edition individuelle etudiant" + if not current_user.has_permission(Permission.ViewEtudData): + raise ScoPermissionDenied() return _etudident_create_or_edit_form(edit=True) diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py index 208ff1e8..8b1c82e9 100644 --- a/tests/api/test_api_etudiants.py +++ b/tests/api/test_api_etudiants.py @@ -63,6 +63,7 @@ from tests.api.tools_test_api import ( BULLETIN_UES_UE_RESSOURCES_RESSOURCE_FIELDS, BULLETIN_UES_UE_SAES_SAE_FIELDS, ETUD_FIELDS, + ETUD_FIELDS_RESTRICTED, FSEM_FIELDS, verify_fields, verify_occurences_ids_etuds, @@ -113,7 +114,7 @@ def test_etudiants_courant(api_headers): assert len(etudiants) == 16 # HARDCODED etud = etudiants[-1] - assert verify_fields(etud, ETUD_FIELDS) is True + assert verify_fields(etud, ETUD_FIELDS_RESTRICTED) is True assert re.match(r"^\d{4}-\d\d-\d\d$", etud["date_naissance"]) @@ -131,7 +132,7 @@ def test_etudiant(api_headers): ) assert r.status_code == 200 etud = r.json() - assert verify_fields(etud, ETUD_FIELDS) is True + assert verify_fields(etud, ETUD_FIELDS_RESTRICTED) is True code_nip = r.json()["code_nip"] code_ine = r.json()["code_ine"] @@ -183,7 +184,7 @@ def test_etudiants(api_headers): assert isinstance(etud, list) assert len(etud) == 1 - fields_ok = verify_fields(etud[0], ETUD_FIELDS) + fields_ok = verify_fields(etud[0], ETUD_FIELDS_RESTRICTED) assert fields_ok is True ######### Test code nip ######### @@ -964,8 +965,10 @@ def test_etudiant_create(api_headers): assert etud["admission"]["commentaire"] == args["admission"]["commentaire"] assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"] assert len(etud["adresses"]) == 1 - assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] - assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # cette fois les données perso ne sont pas publiées + # assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] + # assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # Edition etud = POST_JSON( f"/etudiant/etudid/{etudid}/edit", @@ -981,8 +984,8 @@ def test_etudiant_create(api_headers): assert etud["admission"]["commentaire"] == args["admission"]["commentaire"] assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"] assert len(etud["adresses"]) == 1 - assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] - assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] + # assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] etud = POST_JSON( f"/etudiant/etudid/{etudid}/edit", { diff --git a/tests/api/tools_test_api.py b/tests/api/tools_test_api.py index c7927952..66c3cfc0 100644 --- a/tests/api/tools_test_api.py +++ b/tests/api/tools_test_api.py @@ -44,10 +44,13 @@ DEPARTEMENT_FIELDS = [ "date_creation", ] +# Champs "données personnelles" +ETUD_FIELDS_RESTRICTED = { + "boursier", +} ETUD_FIELDS = { "admission", "adresses", - "boursier", "civilite", "code_ine", "code_nip", @@ -60,7 +63,8 @@ ETUD_FIELDS = { "nationalite", "nom", "prenom", -} +} | ETUD_FIELDS_RESTRICTED + FORMATION_FIELDS = { "dept_id", From 706b21ede7346f0bbe1ae40e690b35a2f4a8c617 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:29:32 +0100 Subject: [PATCH 21/55] =?UTF-8?q?RGPD:=20config.=20coordonn=C3=A9es=20DPO.?= =?UTF-8?q?=20Closes=20#648?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/templates/about.j2 | 9 ++++++++ app/templates/configuration.j2 | 6 +++++ app/views/scodoc.py | 41 ++++++++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/app/templates/about.j2 b/app/templates/about.j2 index b78172cf..4d960b1c 100644 --- a/app/templates/about.j2 +++ b/app/templates/about.j2 @@ -29,6 +29,15 @@

    +
    +

    Coordonnées du délégué à la protection des données (DPO)

    +{% if ScoDocSiteConfig.get("rgpd_coordonnees_dpo") %} + {{ ScoDocSiteConfig.get("rgpd_coordonnees_dpo") }} +{% else %} + non renseigné +{% endif %} +
    +

    Dernières évolutions

    {{ news|safe }} diff --git a/app/templates/configuration.j2 b/app/templates/configuration.j2 index 604310fc..dfe87597 100644 --- a/app/templates/configuration.j2 +++ b/app/templates/configuration.j2 @@ -97,6 +97,12 @@ Heure: {{ time.strftime("%d/%m/%Y %H:%M") }} +

    Protection des données et RGPD

    +
    + +
    + {% endblock %} {% block scripts %} diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 9461a2fc..0967ff16 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -68,6 +68,7 @@ from app.forms.main.config_cas import ConfigCASForm from app.forms.main.config_personalized_links import PersonalizedLinksForm from app.forms.main.create_dept import CreateDeptForm from app.forms.main.role_create import CreateRoleForm +from app.forms.main.config_rgpd import ConfigRGPDForm from app import models from app.models import ( @@ -163,6 +164,31 @@ def config_roles(): ) +@bp.route("/ScoDoc/config_rgpd", methods=["GET", "POST"]) +@admin_required +def config_rgpd(): + """Form configuration RGPD""" + form = ConfigRGPDForm() + if request.method == "POST" and form.cancel.data: # cancel button + return redirect(url_for("scodoc.configuration")) + if form.validate_on_submit(): + if ScoDocSiteConfig.set( + "rgpd_coordonnees_dpo", form.data["rgpd_coordonnees_dpo"] + ): + flash("coordonnées DPO enregistrées") + return redirect(url_for("scodoc.configuration")) + elif request.method == "GET": + form.rgpd_coordonnees_dpo.data = ScoDocSiteConfig.get( + "rgpd_coordonnees_dpo", "" + ) + + return render_template( + "config_rgpd.j2", + form=form, + title="Configuration des fonctions liées au RGPD", + ) + + @bp.route("/ScoDoc/permission_info/") @admin_required def permission_info(perm_name: str): @@ -246,7 +272,7 @@ def config_cas(): """Form config CAS""" form = ConfigCASForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): if ScoDocSiteConfig.set("cas_enable", form.data["cas_enable"]): flash("CAS " + ("activé" if form.data["cas_enable"] else "désactivé")) @@ -322,7 +348,7 @@ def config_assiduites(): """Form config Assiduites""" form = ConfigAssiduitesForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) edt_options = ( ("edt_ics_path", "Chemin vers les calendriers ics"), @@ -409,12 +435,12 @@ def config_codes_decisions(): """Form config codes decisions""" form = CodesDecisionsForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): for code in models.config.CODES_SCODOC_TO_APO: ScoDocSiteConfig.set_code_apo(code, getattr(form, code).data) flash("Codes décisions enregistrés") - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) elif request.method == "GET": for code in models.config.CODES_SCODOC_TO_APO: getattr(form, code).data = ScoDocSiteConfig.get_code_apo(code) @@ -432,7 +458,7 @@ def config_personalized_links(): """Form config liens perso""" form = PersonalizedLinksForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): links = [] for idx in list(form.links_by_id) + ["new"]: @@ -535,9 +561,10 @@ def about(scodoc_dept=None): "version info" return render_template( "about.j2", - version=scu.get_scodoc_version(), - news=sco_version.SCONEWS, logo=scu.icontag("borgne_img"), + news=sco_version.SCONEWS, + ScoDocSiteConfig=ScoDocSiteConfig, + version=scu.get_scodoc_version(), ) From 6908b0b8d2c9816200d47938716f149c3e0a739c Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:30:42 +0100 Subject: [PATCH 22/55] =?UTF-8?q?Deux=20fichiers=20oubli=C3=A9s,=20pour=20?= =?UTF-8?q?#648?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/forms/main/config_rgpd.py | 49 +++++++++++++++++++++++++++++++++++ app/templates/config_rgpd.j2 | 24 +++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 app/forms/main/config_rgpd.py create mode 100644 app/templates/config_rgpd.j2 diff --git a/app/forms/main/config_rgpd.py b/app/forms/main/config_rgpd.py new file mode 100644 index 00000000..d20ff717 --- /dev/null +++ b/app/forms/main/config_rgpd.py @@ -0,0 +1,49 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Formulaire configuration RGPD +""" + +from flask_wtf import FlaskForm +from wtforms import SubmitField +from wtforms.fields.simple import TextAreaField + + +class ConfigRGPDForm(FlaskForm): + "Formulaire paramétrage RGPD" + rgpd_coordonnees_dpo = TextAreaField( + label="Optionnel: coordonnées du DPO", + description="""Le délégué à la protection des données (DPO) est chargé de mettre en œuvre + la conformité au règlement européen sur la protection des données (RGPD) au sein de l’organisme. + Indiquer ici les coordonnées (format libre) qui seront affichées aux utilisateurs de ScoDoc. + """, + render_kw={"rows": 5, "cols": 72}, + ) + + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/templates/config_rgpd.j2 b/app/templates/config_rgpd.j2 new file mode 100644 index 00000000..f02c674e --- /dev/null +++ b/app/templates/config_rgpd.j2 @@ -0,0 +1,24 @@ +{% extends "base.j2" %} +{% import 'wtf.j2' as wtf %} + +{% block app_content %} +

    {{title}}

    + +
    +

    Certaines fonctionnalités de ScoDoc vous aident à vous conformer + au règlement général de protection des données (RGPD) européen. +

    +

    Rappelons que le logiciel ScoDoc est fourni sans aucune garantie, + selon les termes de sa licence GNU GPL et que ni ses auteurs ni + l'association ScoDoc ne sauraient être tenus responsables de l'usage + qui en est fait. +

    +
    + +
    +
    + {{ wtf.quick_form(form) }} +
    +
    + +{% endblock %} \ No newline at end of file From f2f229df4a5d2d14f8a35bb91e0b7f06ec629e2c Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:36:20 +0100 Subject: [PATCH 23/55] =?UTF-8?q?RGPD:=20dur=C3=A9e=20conservation=20logs?= =?UTF-8?q?=20par=20d=C3=A9faut=20(1=20an).=20Closes=20#647?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/etc/scodoc-logrotate | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/etc/scodoc-logrotate b/tools/etc/scodoc-logrotate index 95b0aa3f..42fc2d3f 100644 --- a/tools/etc/scodoc-logrotate +++ b/tools/etc/scodoc-logrotate @@ -1,7 +1,7 @@ /opt/scodoc-data/log/scodoc.log { weekly missingok - rotate 64 + rotate 53 compress notifempty dateext @@ -10,7 +10,7 @@ /opt/scodoc-data/log/scodoc_exc.log { weekly missingok - rotate 64 + rotate 53 compress notifempty dateext From ab116ee9e78c747f4c8f565bf707366d7c24e478 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 20:19:50 +0100 Subject: [PATCH 24/55] typos --- app/api/justificatifs.py | 3 ++- app/scodoc/sco_utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index ff1487a9..9fd61fdc 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -152,7 +152,8 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal @permission_required(Permission.ScoView) def justificatifs_dept(dept_id: int = None, with_query: bool = False): """ - Renvoie tous les justificatifs d'un département (en ajoutant un champs "formsemestre" si possible) + Renvoie tous les justificatifs d'un département + (en ajoutant un champ "formsemestre" si possible) """ # Récupération du département et des étudiants du département diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index e68f2e15..038f0310 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -279,7 +279,7 @@ class NonWorkDays(int, BiDirectionalEnum): ] -def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: +def is_iso_formated(date: str, convert=False) -> bool | datetime.datetime | None: """ Vérifie si une date est au format iso From ff63a32bbe59e2505e060e9bff641aa0c0043c1e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 21:34:28 +0100 Subject: [PATCH 25/55] Adaptation a minima de la table 'poursuites' pour le BUT. Closes #849. --- app/scodoc/sco_poursuite_dut.py | 54 +++++++++++++++++---------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index f4038962..ba866e22 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -38,20 +38,19 @@ from app.comp.res_compat import NotesTableCompat from app.models import FormSemestre import app.scodoc.sco_utils as scu from app.scodoc import sco_assiduites -from app.scodoc import sco_cache from app.scodoc import sco_formsemestre from app.scodoc import sco_groups from app.scodoc import sco_preferences from app.scodoc import sco_etud -import sco_version from app.scodoc.gen_tables import GenTable from app.scodoc.codes_cursus import code_semestre_validant, code_semestre_attente +import sco_version -def etud_get_poursuite_info(sem, etud): +def etud_get_poursuite_info(sem: dict, etud: dict) -> dict: """{ 'nom' : ..., 'semlist' : [ { 'semestre_id': , 'moy' : ... }, {}, ...] }""" - I = {} - I.update(etud) # copie nom, prenom, civilite, ... + infos = {} + infos.update(etud) # copie nom, prenom, civilite, ... # Now add each semester, starting from the first one semlist = [] @@ -92,25 +91,28 @@ def etud_get_poursuite_info(sem, etud): for ue in ues: # on parcourt chaque UE for modimpl in modimpls: # dans chaque UE les modules if modimpl["module"]["ue_id"] == ue["ue_id"]: - codeModule = modimpl["module"]["code"] or "" - noteModule = scu.fmt_note( + code_module = modimpl["module"]["code"] or "" + note_module = scu.fmt_note( nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) ) - if noteModule != "NI": # si étudiant inscrit au module + # si étudiant inscrit au module, sauf BUT + if (note_module != "NI") and not nt.is_apc: if nt.mod_rangs is not None: - rangModule = nt.mod_rangs[modimpl["moduleimpl_id"]][ - 0 - ][etudid] + rang_module = nt.mod_rangs[ + modimpl["moduleimpl_id"] + ][0][etudid] else: - rangModule = "" - modules.append([codeModule, noteModule]) - rangs.append(["rang_" + codeModule, rangModule]) + rang_module = "" + modules.append([code_module, note_module]) + rangs.append(["rang_" + code_module, rang_module]) # Absences nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, nt.sem) - if ( + # En BUT, prend tout, sinon ne prend que les semestre validés par le jury + if nt.is_apc or ( dec - and not sem_descr # not sem_descr pour ne prendre que le semestre validé le plus récent + # not sem_descr pour ne prendre que le semestre validé le plus récent: + and not sem_descr and ( code_semestre_validant(dec["code"]) or code_semestre_attente(dec["code"]) @@ -128,9 +130,8 @@ def etud_get_poursuite_info(sem, etud): ("AbsNonJust", nbabs - nbabsjust), ("AbsJust", nbabsjust), ] - d += ( - moy_ues + rg_ues + modules + rangs - ) # ajout des 2 champs notes des modules et classement dans chaque module + # ajout des 2 champs notes des modules et classement dans chaque module + d += moy_ues + rg_ues + modules + rangs sem_descr = collections.OrderedDict(d) if not sem_descr: sem_descr = collections.OrderedDict( @@ -147,13 +148,14 @@ def etud_get_poursuite_info(sem, etud): sem_descr["semestre_id"] = sem_id semlist.append(sem_descr) - I["semlist"] = semlist - return I + infos["semlist"] = semlist + return infos def _flatten_info(info): - # met la liste des infos semestres "a plat" - # S1_moy, S1_rang, ..., S2_moy, ... + """met la liste des infos semestres "a plat" + S1_moy, S1_rang, ..., S2_moy, ... + """ ids = [] for s in info["semlist"]: for k, v in s.items(): @@ -164,7 +166,7 @@ def _flatten_info(info): return ids -def _getEtudInfoGroupes(group_ids, etat=None): +def _get_etud_info_groupes(group_ids, etat=None): """liste triée d'infos (dict) sur les etudiants du groupe indiqué. Attention: lent, car plusieurs requetes SQL par etudiant ! """ @@ -181,7 +183,7 @@ def _getEtudInfoGroupes(group_ids, etat=None): def formsemestre_poursuite_report(formsemestre_id, fmt="html"): """Table avec informations "poursuite" """ sem = sco_formsemestre.get_formsemestre(formsemestre_id) - etuds = _getEtudInfoGroupes([sco_groups.get_default_group(formsemestre_id)]) + etuds = _get_etud_info_groupes([sco_groups.get_default_group(formsemestre_id)]) infos = [] ids = [] @@ -191,7 +193,7 @@ def formsemestre_poursuite_report(formsemestre_id, fmt="html"): ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + etud["_nom_td_attrs"] = f"""id="{etud['etudid']}" class="etudinfo" """ info = etud_get_poursuite_info(sem, etud) idd = _flatten_info(info) # On recupere la totalite des UEs dans ids From aee4f14b816415e31087943411de7122fc438013 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 21 Jan 2024 18:07:56 +0100 Subject: [PATCH 26/55] =?UTF-8?q?Acc=C3=A8s=20au=20d=C3=A9tail=20d'un=20ju?= =?UTF-8?q?stificatif=20avec=20AbsJustifView:=20closes=20#824?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/__init__.py | 17 ++- app/api/justificatifs.py | 22 ++-- app/models/assiduites.py | 18 +-- app/scodoc/html_sco_header.py | 4 +- app/scodoc/html_sidebar.py | 60 ++++++++- app/scodoc/sco_formsemestre_status.py | 52 +------- app/scodoc/sco_page_etud.py | 4 +- app/scodoc/sco_permissions.py | 8 +- app/tables/liste_assiduites.py | 22 +++- .../pages/ajout_justificatif_etud.j2 | 33 ++++- .../pages/tableau_assiduite_actions.j2 | 6 +- .../widgets/tableau_actions/details.j2 | 116 +++++++++++------- .../widgets/tableau_actions/modifier.j2 | 10 +- app/templates/sidebar_dept.j2 | 9 +- app/views/__init__.py | 5 +- app/views/assiduites.py | 47 +++---- sco_version.py | 2 +- 17 files changed, 274 insertions(+), 161 deletions(-) diff --git a/app/api/__init__.py b/app/api/__init__.py index a6f2b680..fb994bfd 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -3,9 +3,11 @@ from flask_json import as_json from flask import Blueprint from flask import request, g +from flask_login import current_user from app import db from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import AccessDenied, ScoException +from app.scodoc.sco_permissions import Permission api_bp = Blueprint("api", __name__) api_web_bp = Blueprint("apiweb", __name__) @@ -48,13 +50,21 @@ def requested_format(default_format="json", allowed_formats=None): @as_json -def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None): +def get_model_api_object( + model_cls: db.Model, + model_id: int, + join_cls: db.Model = None, + restrict: bool | None = None, +): """ Retourne une réponse contenant la représentation api de l'objet "Model[model_id]" Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> join_cls exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py + + L'agument restrict est passé to_dict, est signale que l'on veut une version restreinte + (sans données personnelles, ou sans informations sur le justificatif d'absence) """ query = model_cls.query.filter_by(id=model_id) if g.scodoc_dept and join_cls is not None: @@ -66,8 +76,9 @@ def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model 404, message=f"{model_cls.__name__} inexistant(e)", ) - - return unique.to_dict(format_api=True) + if restrict is None: + return unique.to_dict(format_api=True) + return unique.to_dict(format_api=True, restrict=restrict) from app.api import tokens diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index 9fd61fdc..a24b0f27 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -53,14 +53,19 @@ def justificatif(justif_id: int = None): "date_fin": "2022-10-31T10:00+01:00", "etat": "valide", "fichier": "archive_id", - "raison": "une raison", + "raison": "une raison", // VIDE si pas le droit "entry_date": "2022-10-31T08:00+01:00", "user_id": 1 or null, } """ - return get_model_api_object(Justificatif, justif_id, Identite) + return get_model_api_object( + Justificatif, + justif_id, + Identite, + restrict=not current_user.has_permission(Permission.AbsJustifView), + ) # etudid @@ -133,8 +138,9 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal # Mise en forme des données puis retour en JSON data_set: list[dict] = [] + restrict = not current_user.has_permission(Permission.AbsJustifView) for just in justificatifs_query.all(): - data = just.to_dict(format_api=True) + data = just.to_dict(format_api=True, restrict=restrict) data_set.append(data) return data_set @@ -172,14 +178,15 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False): justificatifs_query: Query = _filter_manager(request, justificatifs_query) # Mise en forme des données et retour JSON + restrict = not current_user.has_permission(Permission.AbsJustifView) data_set: list[dict] = [] for just in justificatifs_query: - data_set.append(_set_sems(just)) + data_set.append(_set_sems(just, restrict=restrict)) return data_set -def _set_sems(justi: Justificatif) -> dict: +def _set_sems(justi: Justificatif, restrict: bool) -> dict: """ _set_sems Ajoute le formsemestre associé au justificatif s'il existe @@ -192,7 +199,7 @@ def _set_sems(justi: Justificatif) -> dict: dict: La représentation de l'assiduité en dictionnaire """ # Conversion du justificatif en dictionnaire - data = justi.to_dict(format_api=True) + data = justi.to_dict(format_api=True, restrict=restrict) # Récupération du formsemestre de l'assiduité formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict()) @@ -246,9 +253,10 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False): justificatifs_query: Query = _filter_manager(request, justificatifs_query) # Retour des justificatifs en JSON + restrict = not current_user.has_permission(Permission.AbsJustifView) data_set: list[dict] = [] for justi in justificatifs_query.all(): - data = justi.to_dict(format_api=True) + data = justi.to_dict(format_api=True, restrict=restrict) data_set.append(data) return data_set diff --git a/app/models/assiduites.py b/app/models/assiduites.py index c7cf8fa3..b4087e4e 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -88,8 +88,10 @@ class Assiduite(ScoDocModel): lazy="select", ) - def to_dict(self, format_api=True) -> dict: - """Retourne la représentation json de l'assiduité""" + def to_dict(self, format_api=True, restrict: bool | None = None) -> dict: + """Retourne la représentation json de l'assiduité + restrict n'est pas utilisé ici. + """ etat = self.etat user: User | None = None if format_api: @@ -453,8 +455,10 @@ class Justificatif(ScoDocModel): query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) return query.first_or_404() - def to_dict(self, format_api: bool = False) -> dict: - """transformation de l'objet en dictionnaire sérialisable""" + def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict: + """L'objet en dictionnaire sérialisable. + Si restrict, ne donne par la raison et les fichiers et external_data + """ etat = self.etat user: User = self.user if self.user_id is not None else None @@ -469,13 +473,13 @@ class Justificatif(ScoDocModel): "date_debut": self.date_debut, "date_fin": self.date_fin, "etat": etat, - "raison": self.raison, - "fichier": self.fichier, + "raison": None if restrict else self.raison, + "fichier": None if restrict else self.fichier, "entry_date": self.entry_date, "user_id": None if user is None else user.id, # l'uid "user_name": None if user is None else user.user_name, # le login "user_nom_complet": None if user is None else user.get_nomcomplet(), - "external_data": self.external_data, + "external_data": None if restrict else self.external_data, } return data diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 2e06370a..b76a8e77 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -145,7 +145,9 @@ def sco_header( etudid=None, formsemestre_id=None, ): - "Main HTML page header for ScoDoc" + """Main HTML page header for ScoDoc + Utilisé dans les anciennes pages. Les nouvelles pages utilisent le template Jinja. + """ from app.scodoc.sco_formsemestre_status import formsemestre_page_title if etudid is not None: diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index dce59627..c0c732ce 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -32,12 +32,66 @@ from flask import render_template, url_for from flask import g, request from flask_login import current_user +from app import db +from app.models import Evaluation, GroupDescr, ModuleImpl, Partition import app.scodoc.sco_utils as scu from app.scodoc import sco_preferences from app.scodoc.sco_permissions import Permission from sco_version import SCOVERSION +def retreive_formsemestre_from_request() -> int: + """Cherche si on a de quoi déduire le semestre affiché à partir des + arguments de la requête: + formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id + Returns None si pas défini. + """ + if request.method == "GET": + args = request.args + elif request.method == "POST": + args = request.form + else: + return None + formsemestre_id = None + # Search formsemestre + group_ids = args.get("group_ids", []) + if "formsemestre_id" in args: + formsemestre_id = args["formsemestre_id"] + elif "moduleimpl_id" in args and args["moduleimpl_id"]: + modimpl = db.session.get(ModuleImpl, args["moduleimpl_id"]) + if not modimpl: + return None # suppressed ? + formsemestre_id = modimpl.formsemestre_id + elif "evaluation_id" in args: + evaluation = db.session.get(Evaluation, args["evaluation_id"]) + if not evaluation: + return None # evaluation suppressed ? + formsemestre_id = evaluation.moduleimpl.formsemestre_id + elif "group_id" in args: + group = db.session.get(GroupDescr, args["group_id"]) + if not group: + return None + formsemestre_id = group.partition.formsemestre_id + elif group_ids: + if isinstance(group_ids, str): + group_ids = group_ids.split(",") + group_id = group_ids[0] + group = db.session.get(GroupDescr, group_id) + if not group: + return None + formsemestre_id = group.partition.formsemestre_id + elif "partition_id" in args: + partition = db.session.get(Partition, args["partition_id"]) + if not partition: + return None + formsemestre_id = partition.formsemestre_id + + if formsemestre_id is None: + return None # no current formsemestre + + return int(formsemestre_id) + + def sidebar_common(): "partie commune à toutes les sidebar" home_link = url_for("scodoc.index", scodoc_dept=g.scodoc_dept) @@ -129,13 +183,17 @@ def sidebar(etudid: int = None): ) H.append("
      ") if current_user.has_permission(Permission.AbsChange): + # essaie de conserver le semestre actuellement en vue + cur_formsemestre_id = retreive_formsemestre_from_request() H.append( f"""
    • Ajouter
    • Justifier
    • """ ) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 1cea6daf..20d3ec65 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -76,6 +76,7 @@ from app.scodoc import sco_moduleimpl from app.scodoc import sco_preferences from app.scodoc import sco_users from app.scodoc.gen_tables import GenTable +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_formsemestre_custommenu import formsemestre_custommenu_html import sco_version @@ -476,57 +477,6 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: return "\n".join(H) -def retreive_formsemestre_from_request() -> int: - """Cherche si on a de quoi déduire le semestre affiché à partir des - arguments de la requête: - formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id - Returns None si pas défini. - """ - if request.method == "GET": - args = request.args - elif request.method == "POST": - args = request.form - else: - return None - formsemestre_id = None - # Search formsemestre - group_ids = args.get("group_ids", []) - if "formsemestre_id" in args: - formsemestre_id = args["formsemestre_id"] - elif "moduleimpl_id" in args and args["moduleimpl_id"]: - modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=args["moduleimpl_id"]) - if not modimpl: - return None # suppressed ? - modimpl = modimpl[0] - formsemestre_id = modimpl["formsemestre_id"] - elif "evaluation_id" in args: - E = sco_evaluation_db.get_evaluations_dict( - {"evaluation_id": args["evaluation_id"]} - ) - if not E: - return None # evaluation suppressed ? - E = E[0] - modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - formsemestre_id = modimpl["formsemestre_id"] - elif "group_id" in args: - group = sco_groups.get_group(args["group_id"]) - formsemestre_id = group["formsemestre_id"] - elif group_ids: - if isinstance(group_ids, str): - group_ids = group_ids.split(",") - group_id = group_ids[0] - group = sco_groups.get_group(group_id) - formsemestre_id = group["formsemestre_id"] - elif "partition_id" in args: - partition = sco_groups.get_partition(args["partition_id"]) - formsemestre_id = partition["formsemestre_id"] - - if not formsemestre_id: - return None # no current formsemestre - - return int(formsemestre_id) - - # Element HTML decrivant un semestre (barre de menu et infos) def formsemestre_page_title(formsemestre_id=None): """Element HTML decrivant un semestre (barre de menu et infos) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index d34a933f..1f5d572f 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -46,11 +46,11 @@ from app.scodoc import ( sco_bac, sco_cursus, sco_etud, - sco_formsemestre_status, sco_groups, sco_permissions_check, sco_report, ) +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_bulletins import etud_descr_situation_semestre from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table @@ -751,7 +751,7 @@ def etud_info_html(etudid, with_photo="1", debug=False): """An HTML div with basic information and links about this etud. Used for popups information windows. """ - formsemestre_id = sco_formsemestre_status.retreive_formsemestre_from_request() + formsemestre_id = retreive_formsemestre_from_request() with_photo = int(with_photo) etud = Identite.get_etud(etudid) diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index bf871252..6e5e53ee 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -24,7 +24,7 @@ _SCO_PERMISSIONS = ( (1 << 10, "EditAllNotes", "Modifier toutes les notes"), (1 << 11, "EditAllEvals", "Modifier toutes les évaluations"), (1 << 12, "EditFormSemestre", "Mettre en place une formation (créer un semestre)"), - (1 << 13, "AbsChange", "Saisir des absences"), + (1 << 13, "AbsChange", "Saisir des absences ou justificatifs"), (1 << 14, "AbsAddBillet", "Saisir des billets d'absences"), # changer adresse/photo ou pour envoyer bulletins par mail ou pour debouche (1 << 15, "EtudChangeAdr", "Changer les adresses d'étudiants"), @@ -63,7 +63,11 @@ _SCO_PERMISSIONS = ( # # XXX inutilisée ? (1 << 40, "EtudChangePhoto", "Modifier la photo d'un étudiant"), # Permissions du module Assiduité) - (1 << 50, "AbsJustifView", "Visualisation des fichiers justificatifs"), + ( + 1 << 50, + "AbsJustifView", + "Visualisation du détail des justificatifs (motif, fichiers)", + ), # Attention: les permissions sont codées sur 64 bits. ) diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index 24a449b5..e2f93788 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -1,6 +1,7 @@ from datetime import datetime from flask import url_for +from flask_login import current_user from flask_sqlalchemy.query import Query from sqlalchemy import desc, literal, union, asc @@ -10,6 +11,7 @@ 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 +from app.scodoc.sco_permissions import Permission class Pagination: @@ -107,6 +109,11 @@ class ListeAssiJusti(tb.Table): self.total_page: int = None + # Accès aux détail des justificatifs ? + self.can_view_justif_detail = current_user.has_permission( + Permission.AbsJustifView + ) + # les lignes du tableau self.rows: list["RowAssiJusti"] = [] @@ -342,7 +349,7 @@ class RowAssiJusti(tb.Row): # Type d'objet self._type() - # En excel, on export les "vraes dates". + # En excel, on export les "vraies 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() @@ -470,10 +477,21 @@ class RowAssiJusti(tb.Row): def _optionnelles(self) -> None: if self.table.options.show_desc: + if self.ligne.get("type") == "justificatif": + # protection de la "raison" + if ( + self.ligne["user_id"] == current_user.id + or self.table.can_view_justif_detail + ): + description = self.ligne["desc"] if self.ligne["desc"] else "" + else: + description = "(cachée)" + else: + description = self.ligne["desc"] if self.ligne["desc"] else "" self.add_cell( "description", "Description", - self.ligne["desc"] if self.ligne["desc"] else "", + description, ) if self.table.options.show_module: if self.ligne["type"] == "assiduite": diff --git a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 index 61bd3197..27349ba5 100644 --- a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 @@ -17,6 +17,9 @@ form#ajout-justificatif-etud { form#ajout-justificatif-etud > div { margin-bottom: 16px; } +fieldset > div { + margin-bottom: 12px; +} div.fichiers { margin-top: 16px; margin-bottom: 32px; @@ -33,9 +36,20 @@ div.submit { div.submit > input { margin-right: 16px; } +.info-saisie { + margin-top: 12px; + margin-bottom: 12px; + font-style: italic; +}
      -

      Justifier des absences ou retards

      +

      {{title|safe}}

      + + {% if justif %} +
      + Saisie par {{justif.user.get_prenomnom()}} le {{justif.entry_date.strftime("%d/%m/%Y à %H:%M")}} +
      + {% endif %}
      @@ -72,16 +86,24 @@ div.submit > input {
      {# Raison #}
      -
      {{ form.raison.label }}
      - {{ form.raison() }} - {{ render_field_errors(form, 'raison') }} + {% if (not justif) or can_view_justif_detail %} +
      {{ form.raison.label }}
      + {{ form.raison() }} + {{ render_field_errors(form, 'raison') }} +
      La raison sera visible aux utilisateurs ayant le droit + AbsJustifView et à celui ayant déposé le justificatif + {%- if justif %} ({{justif.user.get_prenomnom()}}){%- endif -%}. +
      + {% else %} +
      raison confidentielle
      + {% endif %}
      {# Liste des fichiers existants #} {% if justif and nb_files > 0 %}
      {{nb_files}} fichiers justificatifs déposés {% if filenames|length < nb_files %} - , dont {{filenames|length}} vous sont accessibles + , dont {{filenames|length}} vous {{'sont accessibles' if filenames|length > 1 else 'est accessible'}} {% endif %}
      @@ -104,6 +126,7 @@ div.submit > input { {{ form.entry_date.label }} : {{ form.entry_date }} laisser vide pour date courante {{ render_field_errors(form, 'entry_date') }} + {# Submit #}
      {{ form.submit }} {{ form.cancel }} diff --git a/app/templates/assiduites/pages/tableau_assiduite_actions.j2 b/app/templates/assiduites/pages/tableau_assiduite_actions.j2 index 903fceba..705aaec3 100644 --- a/app/templates/assiduites/pages/tableau_assiduite_actions.j2 +++ b/app/templates/assiduites/pages/tableau_assiduite_actions.j2 @@ -10,11 +10,11 @@ {% if action == "modifier" %} {% include "assiduites/widgets/tableau_actions/modifier.j2" %} -{% else%} +{% else %} {% include "assiduites/widgets/tableau_actions/details.j2" %} {% endif %} -{% if not current_user.has_permission(sco.Permission.AbsJustifView)%} +{% if not current_user.has_permission(sco.Permission.AbsJustifView) %}
      Vous n'avez pas la permission d'ouvrir les fichiers justificatifs déposés par d'autres personnes. @@ -22,7 +22,7 @@ {% endif %} """, + """accès aux données personnelles interdit""" + if not can_view_etud_data + else "", ] ) H.append("
      ") @@ -708,41 +724,45 @@ def groups_table( H.extend( [ tab.html(), - "", ] ) @@ -901,14 +926,19 @@ def tab_absences_html(groups_infos, etat=None): """ ) # Lien pour ajout fichiers étudiants - if authuser.has_permission(Permission.EtudAddAnnotations): + text = "Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)" + if authuser.has_permission( + Permission.EtudAddAnnotations + ) and authuser.has_permission(Permission.ViewEtudData): H.append( f"""
    • Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)
    • """ + )}">{text}""" ) + else: + H.append(f"""
    • {text}
    • """) H.append("
    ") return "".join(H) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 1bdb148c..d34a933f 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -198,7 +198,9 @@ def fiche_etud(etudid=None): info["etudfoto"] = etud.photo_html() # Champ dépendant des permissions: - if current_user.has_permission(Permission.EtudChangeAdr): + if current_user.has_permission( + Permission.EtudChangeAdr + ) and current_user.has_permission(Permission.ViewEtudData): info[ "modifadresse" ] = f"""Fichiers associés' - + sco_archives_etud.etud_list_archives_html(etud) + "" + if restrict_etud_data + else ( + '
    Fichiers associés
    ' + + sco_archives_etud.etud_list_archives_html(etud) + ) ) # Devenir de l'étudiant: @@ -713,7 +719,8 @@ def menus_etud(etudid): "title": "Changer les données identité/admission", "endpoint": "scolar.etudident_edit_form", "args": {"etudid": etud["etudid"]}, - "enabled": authuser.has_permission(Permission.EtudInscrit), + "enabled": authuser.has_permission(Permission.EtudInscrit) + and authuser.has_permission(Permission.ViewEtudData), }, { "title": "Copier dans un autre département...", @@ -748,7 +755,7 @@ def etud_info_html(etudid, with_photo="1", debug=False): with_photo = int(with_photo) etud = Identite.get_etud(etudid) - photo_html = etud.photo_html(etud, title="fiche de " + etud.nomprenom) + photo_html = etud.photo_html(title="fiche de " + etud.nomprenom) code_cursus, _ = sco_report.get_code_cursus_etud( etud, formsemestres=etud.get_formsemestres(), prefix="S", separator=", " ) @@ -758,17 +765,21 @@ def etud_info_html(etudid, with_photo="1", debug=False):
    + }">{etud.nomprenom}
    Bac: {bac_abbrev}
    {code_cursus}
    """ # Informations sur l'etudiant dans le semestre courant: - formsemestre = None if formsemestre_id: # un semestre est spécifié par la page formsemestre = FormSemestre.get_formsemestre(formsemestre_id) - elif inscription_courante: # le semestre "en cours" pour l'étudiant - formsemestre = inscription_courante.formsemestre + else: + # le semestre "en cours" pour l'étudiant + inscription_courante = etud.inscription_courante() + formsemestre = ( + inscription_courante.formsemestre if inscription_courante else None + ) + if formsemestre: groups = sco_groups.get_etud_groups(etudid, formsemestre.id) grc = sco_groups.listgroups_abbrev(groups) diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index a9c437b8..bf871252 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -37,7 +37,11 @@ _SCO_PERMISSIONS = ( # aussi pour demissions, diplomes: (1 << 17, "EtudInscrit", "Inscrire des étudiants"), # aussi pour archives: - (1 << 18, "EtudAddAnnotations", "Éditer les annotations"), + ( + 1 << 18, + "EtudAddAnnotations", + "Éditer les annotations (et fichiers) sur étudiants", + ), # inutilisée (1 << 19, "ScoEntrepriseView", "Voir la section 'entreprises'"), # inutilisée (1 << 20, "EntrepriseChange", "Modifier les entreprises"), # XXX inutilisée ? (1 << 21, "EditPVJury", "Éditer les PV de jury"), diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index c6d62c49..948554f2 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -172,6 +172,11 @@ form#group_selector { margin-bottom: 3px; } +/* Text lien ou itms ,non autorisés pour l'utilisateur courant */ +.unauthorized { + color: grey; +} + /* ----- bandeau haut ------ */ span.bandeaugtr { width: 100%; diff --git a/app/views/notes.py b/app/views/notes.py index ae977682..a6e73ac3 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -3230,7 +3230,7 @@ sco_publish( sco_publish( "/formsemestre_etuds_lycees", sco_lycee.formsemestre_etuds_lycees, - Permission.ScoView, + Permission.ViewEtudData, ) sco_publish( "/scodoc_table_etuds_lycees", diff --git a/app/views/scolar.py b/app/views/scolar.py index d842dc1b..7e1837ce 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -442,7 +442,7 @@ sco_publish( sco_publish( "/groups_export_annotations", sco_groups_exports.groups_export_annotations, - Permission.ScoView, + Permission.ViewEtudData, ) @@ -630,27 +630,27 @@ sco_publish("/fiche_etud", sco_page_etud.fiche_etud, Permission.ScoView) sco_publish( "/etud_upload_file_form", sco_archives_etud.etud_upload_file_form, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) sco_publish( "/etud_delete_archive", sco_archives_etud.etud_delete_archive, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) sco_publish( "/etud_get_archived_file", sco_archives_etud.etud_get_archived_file, - Permission.ScoView, + Permission.ViewEtudData, ) sco_publish( "/etudarchive_import_files_form", sco_archives_etud.etudarchive_import_files_form, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) @@ -758,6 +758,8 @@ def doSuppressAnnotation(etudid, annotation_id): @scodoc7func def form_change_coordonnees(etudid): "edit coordonnees etudiant" + if not current_user.has_permission(Permission.ViewEtudData): + raise ScoPermissionDenied() etud = Identite.get_etud(etudid) cnx = ndb.GetDBConnexion() adrs = sco_etud.adresse_list(cnx, {"etudid": etudid}) @@ -1344,6 +1346,8 @@ def etudident_create_form(): @scodoc7func def etudident_edit_form(): "formulaire edition individuelle etudiant" + if not current_user.has_permission(Permission.ViewEtudData): + raise ScoPermissionDenied() return _etudident_create_or_edit_form(edit=True) diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py index 208ff1e8..8b1c82e9 100644 --- a/tests/api/test_api_etudiants.py +++ b/tests/api/test_api_etudiants.py @@ -63,6 +63,7 @@ from tests.api.tools_test_api import ( BULLETIN_UES_UE_RESSOURCES_RESSOURCE_FIELDS, BULLETIN_UES_UE_SAES_SAE_FIELDS, ETUD_FIELDS, + ETUD_FIELDS_RESTRICTED, FSEM_FIELDS, verify_fields, verify_occurences_ids_etuds, @@ -113,7 +114,7 @@ def test_etudiants_courant(api_headers): assert len(etudiants) == 16 # HARDCODED etud = etudiants[-1] - assert verify_fields(etud, ETUD_FIELDS) is True + assert verify_fields(etud, ETUD_FIELDS_RESTRICTED) is True assert re.match(r"^\d{4}-\d\d-\d\d$", etud["date_naissance"]) @@ -131,7 +132,7 @@ def test_etudiant(api_headers): ) assert r.status_code == 200 etud = r.json() - assert verify_fields(etud, ETUD_FIELDS) is True + assert verify_fields(etud, ETUD_FIELDS_RESTRICTED) is True code_nip = r.json()["code_nip"] code_ine = r.json()["code_ine"] @@ -183,7 +184,7 @@ def test_etudiants(api_headers): assert isinstance(etud, list) assert len(etud) == 1 - fields_ok = verify_fields(etud[0], ETUD_FIELDS) + fields_ok = verify_fields(etud[0], ETUD_FIELDS_RESTRICTED) assert fields_ok is True ######### Test code nip ######### @@ -964,8 +965,10 @@ def test_etudiant_create(api_headers): assert etud["admission"]["commentaire"] == args["admission"]["commentaire"] assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"] assert len(etud["adresses"]) == 1 - assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] - assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # cette fois les données perso ne sont pas publiées + # assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] + # assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # Edition etud = POST_JSON( f"/etudiant/etudid/{etudid}/edit", @@ -981,8 +984,8 @@ def test_etudiant_create(api_headers): assert etud["admission"]["commentaire"] == args["admission"]["commentaire"] assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"] assert len(etud["adresses"]) == 1 - assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] - assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] + # assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] etud = POST_JSON( f"/etudiant/etudid/{etudid}/edit", { diff --git a/tests/api/tools_test_api.py b/tests/api/tools_test_api.py index c7927952..66c3cfc0 100644 --- a/tests/api/tools_test_api.py +++ b/tests/api/tools_test_api.py @@ -44,10 +44,13 @@ DEPARTEMENT_FIELDS = [ "date_creation", ] +# Champs "données personnelles" +ETUD_FIELDS_RESTRICTED = { + "boursier", +} ETUD_FIELDS = { "admission", "adresses", - "boursier", "civilite", "code_ine", "code_nip", @@ -60,7 +63,8 @@ ETUD_FIELDS = { "nationalite", "nom", "prenom", -} +} | ETUD_FIELDS_RESTRICTED + FORMATION_FIELDS = { "dept_id", From 4917034b6d7eab66be29aa59325128a41fcd1b39 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:29:32 +0100 Subject: [PATCH 29/55] =?UTF-8?q?RGPD:=20config.=20coordonn=C3=A9es=20DPO.?= =?UTF-8?q?=20Closes=20#648?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/templates/about.j2 | 9 ++++++++ app/templates/configuration.j2 | 6 +++++ app/views/scodoc.py | 41 ++++++++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/app/templates/about.j2 b/app/templates/about.j2 index b78172cf..4d960b1c 100644 --- a/app/templates/about.j2 +++ b/app/templates/about.j2 @@ -29,6 +29,15 @@

    +
    +

    Coordonnées du délégué à la protection des données (DPO)

    +{% if ScoDocSiteConfig.get("rgpd_coordonnees_dpo") %} + {{ ScoDocSiteConfig.get("rgpd_coordonnees_dpo") }} +{% else %} + non renseigné +{% endif %} +
    +

    Dernières évolutions

    {{ news|safe }} diff --git a/app/templates/configuration.j2 b/app/templates/configuration.j2 index 604310fc..dfe87597 100644 --- a/app/templates/configuration.j2 +++ b/app/templates/configuration.j2 @@ -97,6 +97,12 @@ Heure: {{ time.strftime("%d/%m/%Y %H:%M") }} +

    Protection des données et RGPD

    +
    + +
    + {% endblock %} {% block scripts %} diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 9461a2fc..0967ff16 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -68,6 +68,7 @@ from app.forms.main.config_cas import ConfigCASForm from app.forms.main.config_personalized_links import PersonalizedLinksForm from app.forms.main.create_dept import CreateDeptForm from app.forms.main.role_create import CreateRoleForm +from app.forms.main.config_rgpd import ConfigRGPDForm from app import models from app.models import ( @@ -163,6 +164,31 @@ def config_roles(): ) +@bp.route("/ScoDoc/config_rgpd", methods=["GET", "POST"]) +@admin_required +def config_rgpd(): + """Form configuration RGPD""" + form = ConfigRGPDForm() + if request.method == "POST" and form.cancel.data: # cancel button + return redirect(url_for("scodoc.configuration")) + if form.validate_on_submit(): + if ScoDocSiteConfig.set( + "rgpd_coordonnees_dpo", form.data["rgpd_coordonnees_dpo"] + ): + flash("coordonnées DPO enregistrées") + return redirect(url_for("scodoc.configuration")) + elif request.method == "GET": + form.rgpd_coordonnees_dpo.data = ScoDocSiteConfig.get( + "rgpd_coordonnees_dpo", "" + ) + + return render_template( + "config_rgpd.j2", + form=form, + title="Configuration des fonctions liées au RGPD", + ) + + @bp.route("/ScoDoc/permission_info/") @admin_required def permission_info(perm_name: str): @@ -246,7 +272,7 @@ def config_cas(): """Form config CAS""" form = ConfigCASForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): if ScoDocSiteConfig.set("cas_enable", form.data["cas_enable"]): flash("CAS " + ("activé" if form.data["cas_enable"] else "désactivé")) @@ -322,7 +348,7 @@ def config_assiduites(): """Form config Assiduites""" form = ConfigAssiduitesForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) edt_options = ( ("edt_ics_path", "Chemin vers les calendriers ics"), @@ -409,12 +435,12 @@ def config_codes_decisions(): """Form config codes decisions""" form = CodesDecisionsForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): for code in models.config.CODES_SCODOC_TO_APO: ScoDocSiteConfig.set_code_apo(code, getattr(form, code).data) flash("Codes décisions enregistrés") - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) elif request.method == "GET": for code in models.config.CODES_SCODOC_TO_APO: getattr(form, code).data = ScoDocSiteConfig.get_code_apo(code) @@ -432,7 +458,7 @@ def config_personalized_links(): """Form config liens perso""" form = PersonalizedLinksForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): links = [] for idx in list(form.links_by_id) + ["new"]: @@ -535,9 +561,10 @@ def about(scodoc_dept=None): "version info" return render_template( "about.j2", - version=scu.get_scodoc_version(), - news=sco_version.SCONEWS, logo=scu.icontag("borgne_img"), + news=sco_version.SCONEWS, + ScoDocSiteConfig=ScoDocSiteConfig, + version=scu.get_scodoc_version(), ) From b8eb8bb77fad3fe9acaf5aa741af1ee0a396cb06 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:30:42 +0100 Subject: [PATCH 30/55] =?UTF-8?q?Deux=20fichiers=20oubli=C3=A9s,=20pour=20?= =?UTF-8?q?#648?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/forms/main/config_rgpd.py | 49 +++++++++++++++++++++++++++++++++++ app/templates/config_rgpd.j2 | 24 +++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 app/forms/main/config_rgpd.py create mode 100644 app/templates/config_rgpd.j2 diff --git a/app/forms/main/config_rgpd.py b/app/forms/main/config_rgpd.py new file mode 100644 index 00000000..d20ff717 --- /dev/null +++ b/app/forms/main/config_rgpd.py @@ -0,0 +1,49 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Formulaire configuration RGPD +""" + +from flask_wtf import FlaskForm +from wtforms import SubmitField +from wtforms.fields.simple import TextAreaField + + +class ConfigRGPDForm(FlaskForm): + "Formulaire paramétrage RGPD" + rgpd_coordonnees_dpo = TextAreaField( + label="Optionnel: coordonnées du DPO", + description="""Le délégué à la protection des données (DPO) est chargé de mettre en œuvre + la conformité au règlement européen sur la protection des données (RGPD) au sein de l’organisme. + Indiquer ici les coordonnées (format libre) qui seront affichées aux utilisateurs de ScoDoc. + """, + render_kw={"rows": 5, "cols": 72}, + ) + + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/templates/config_rgpd.j2 b/app/templates/config_rgpd.j2 new file mode 100644 index 00000000..f02c674e --- /dev/null +++ b/app/templates/config_rgpd.j2 @@ -0,0 +1,24 @@ +{% extends "base.j2" %} +{% import 'wtf.j2' as wtf %} + +{% block app_content %} +

    {{title}}

    + +
    +

    Certaines fonctionnalités de ScoDoc vous aident à vous conformer + au règlement général de protection des données (RGPD) européen. +

    +

    Rappelons que le logiciel ScoDoc est fourni sans aucune garantie, + selon les termes de sa licence GNU GPL et que ni ses auteurs ni + l'association ScoDoc ne sauraient être tenus responsables de l'usage + qui en est fait. +

    +
    + +
    +
    + {{ wtf.quick_form(form) }} +
    +
    + +{% endblock %} \ No newline at end of file From a65c1d3c4a7f34b8a12ad8fcf5356837eb2c82f2 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:36:20 +0100 Subject: [PATCH 31/55] =?UTF-8?q?RGPD:=20dur=C3=A9e=20conservation=20logs?= =?UTF-8?q?=20par=20d=C3=A9faut=20(1=20an).=20Closes=20#647?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/etc/scodoc-logrotate | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/etc/scodoc-logrotate b/tools/etc/scodoc-logrotate index 95b0aa3f..42fc2d3f 100644 --- a/tools/etc/scodoc-logrotate +++ b/tools/etc/scodoc-logrotate @@ -1,7 +1,7 @@ /opt/scodoc-data/log/scodoc.log { weekly missingok - rotate 64 + rotate 53 compress notifempty dateext @@ -10,7 +10,7 @@ /opt/scodoc-data/log/scodoc_exc.log { weekly missingok - rotate 64 + rotate 53 compress notifempty dateext From 7d2d5a3ea9a38063ccbdc7db2754649748fe9dc5 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 20:19:50 +0100 Subject: [PATCH 32/55] typos --- app/api/justificatifs.py | 3 ++- app/scodoc/sco_utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index ff1487a9..9fd61fdc 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -152,7 +152,8 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal @permission_required(Permission.ScoView) def justificatifs_dept(dept_id: int = None, with_query: bool = False): """ - Renvoie tous les justificatifs d'un département (en ajoutant un champs "formsemestre" si possible) + Renvoie tous les justificatifs d'un département + (en ajoutant un champ "formsemestre" si possible) """ # Récupération du département et des étudiants du département diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index e68f2e15..038f0310 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -279,7 +279,7 @@ class NonWorkDays(int, BiDirectionalEnum): ] -def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: +def is_iso_formated(date: str, convert=False) -> bool | datetime.datetime | None: """ Vérifie si une date est au format iso From f09b2028e2557c99442f310f48f22290ef119bbc Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 21:34:28 +0100 Subject: [PATCH 33/55] Adaptation a minima de la table 'poursuites' pour le BUT. Closes #849. --- app/scodoc/sco_poursuite_dut.py | 54 +++++++++++++++++---------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index f4038962..ba866e22 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -38,20 +38,19 @@ from app.comp.res_compat import NotesTableCompat from app.models import FormSemestre import app.scodoc.sco_utils as scu from app.scodoc import sco_assiduites -from app.scodoc import sco_cache from app.scodoc import sco_formsemestre from app.scodoc import sco_groups from app.scodoc import sco_preferences from app.scodoc import sco_etud -import sco_version from app.scodoc.gen_tables import GenTable from app.scodoc.codes_cursus import code_semestre_validant, code_semestre_attente +import sco_version -def etud_get_poursuite_info(sem, etud): +def etud_get_poursuite_info(sem: dict, etud: dict) -> dict: """{ 'nom' : ..., 'semlist' : [ { 'semestre_id': , 'moy' : ... }, {}, ...] }""" - I = {} - I.update(etud) # copie nom, prenom, civilite, ... + infos = {} + infos.update(etud) # copie nom, prenom, civilite, ... # Now add each semester, starting from the first one semlist = [] @@ -92,25 +91,28 @@ def etud_get_poursuite_info(sem, etud): for ue in ues: # on parcourt chaque UE for modimpl in modimpls: # dans chaque UE les modules if modimpl["module"]["ue_id"] == ue["ue_id"]: - codeModule = modimpl["module"]["code"] or "" - noteModule = scu.fmt_note( + code_module = modimpl["module"]["code"] or "" + note_module = scu.fmt_note( nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) ) - if noteModule != "NI": # si étudiant inscrit au module + # si étudiant inscrit au module, sauf BUT + if (note_module != "NI") and not nt.is_apc: if nt.mod_rangs is not None: - rangModule = nt.mod_rangs[modimpl["moduleimpl_id"]][ - 0 - ][etudid] + rang_module = nt.mod_rangs[ + modimpl["moduleimpl_id"] + ][0][etudid] else: - rangModule = "" - modules.append([codeModule, noteModule]) - rangs.append(["rang_" + codeModule, rangModule]) + rang_module = "" + modules.append([code_module, note_module]) + rangs.append(["rang_" + code_module, rang_module]) # Absences nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, nt.sem) - if ( + # En BUT, prend tout, sinon ne prend que les semestre validés par le jury + if nt.is_apc or ( dec - and not sem_descr # not sem_descr pour ne prendre que le semestre validé le plus récent + # not sem_descr pour ne prendre que le semestre validé le plus récent: + and not sem_descr and ( code_semestre_validant(dec["code"]) or code_semestre_attente(dec["code"]) @@ -128,9 +130,8 @@ def etud_get_poursuite_info(sem, etud): ("AbsNonJust", nbabs - nbabsjust), ("AbsJust", nbabsjust), ] - d += ( - moy_ues + rg_ues + modules + rangs - ) # ajout des 2 champs notes des modules et classement dans chaque module + # ajout des 2 champs notes des modules et classement dans chaque module + d += moy_ues + rg_ues + modules + rangs sem_descr = collections.OrderedDict(d) if not sem_descr: sem_descr = collections.OrderedDict( @@ -147,13 +148,14 @@ def etud_get_poursuite_info(sem, etud): sem_descr["semestre_id"] = sem_id semlist.append(sem_descr) - I["semlist"] = semlist - return I + infos["semlist"] = semlist + return infos def _flatten_info(info): - # met la liste des infos semestres "a plat" - # S1_moy, S1_rang, ..., S2_moy, ... + """met la liste des infos semestres "a plat" + S1_moy, S1_rang, ..., S2_moy, ... + """ ids = [] for s in info["semlist"]: for k, v in s.items(): @@ -164,7 +166,7 @@ def _flatten_info(info): return ids -def _getEtudInfoGroupes(group_ids, etat=None): +def _get_etud_info_groupes(group_ids, etat=None): """liste triée d'infos (dict) sur les etudiants du groupe indiqué. Attention: lent, car plusieurs requetes SQL par etudiant ! """ @@ -181,7 +183,7 @@ def _getEtudInfoGroupes(group_ids, etat=None): def formsemestre_poursuite_report(formsemestre_id, fmt="html"): """Table avec informations "poursuite" """ sem = sco_formsemestre.get_formsemestre(formsemestre_id) - etuds = _getEtudInfoGroupes([sco_groups.get_default_group(formsemestre_id)]) + etuds = _get_etud_info_groupes([sco_groups.get_default_group(formsemestre_id)]) infos = [] ids = [] @@ -191,7 +193,7 @@ def formsemestre_poursuite_report(formsemestre_id, fmt="html"): ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + etud["_nom_td_attrs"] = f"""id="{etud['etudid']}" class="etudinfo" """ info = etud_get_poursuite_info(sem, etud) idd = _flatten_info(info) # On recupere la totalite des UEs dans ids From 555e8af818ec4e245c9787d6b1d6d912158dab95 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 21 Jan 2024 18:07:56 +0100 Subject: [PATCH 34/55] =?UTF-8?q?Acc=C3=A8s=20au=20d=C3=A9tail=20d'un=20ju?= =?UTF-8?q?stificatif=20avec=20AbsJustifView:=20closes=20#824?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/__init__.py | 17 ++- app/api/justificatifs.py | 22 ++-- app/models/assiduites.py | 18 +-- app/scodoc/html_sco_header.py | 4 +- app/scodoc/html_sidebar.py | 60 ++++++++- app/scodoc/sco_formsemestre_status.py | 52 +------- app/scodoc/sco_page_etud.py | 4 +- app/scodoc/sco_permissions.py | 8 +- app/tables/liste_assiduites.py | 22 +++- .../pages/ajout_justificatif_etud.j2 | 33 ++++- .../pages/tableau_assiduite_actions.j2 | 6 +- .../widgets/tableau_actions/details.j2 | 116 +++++++++++------- .../widgets/tableau_actions/modifier.j2 | 10 +- app/templates/sidebar_dept.j2 | 9 +- app/views/__init__.py | 5 +- app/views/assiduites.py | 47 +++---- sco_version.py | 2 +- 17 files changed, 274 insertions(+), 161 deletions(-) diff --git a/app/api/__init__.py b/app/api/__init__.py index a6f2b680..fb994bfd 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -3,9 +3,11 @@ from flask_json import as_json from flask import Blueprint from flask import request, g +from flask_login import current_user from app import db from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import AccessDenied, ScoException +from app.scodoc.sco_permissions import Permission api_bp = Blueprint("api", __name__) api_web_bp = Blueprint("apiweb", __name__) @@ -48,13 +50,21 @@ def requested_format(default_format="json", allowed_formats=None): @as_json -def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None): +def get_model_api_object( + model_cls: db.Model, + model_id: int, + join_cls: db.Model = None, + restrict: bool | None = None, +): """ Retourne une réponse contenant la représentation api de l'objet "Model[model_id]" Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> join_cls exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py + + L'agument restrict est passé to_dict, est signale que l'on veut une version restreinte + (sans données personnelles, ou sans informations sur le justificatif d'absence) """ query = model_cls.query.filter_by(id=model_id) if g.scodoc_dept and join_cls is not None: @@ -66,8 +76,9 @@ def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model 404, message=f"{model_cls.__name__} inexistant(e)", ) - - return unique.to_dict(format_api=True) + if restrict is None: + return unique.to_dict(format_api=True) + return unique.to_dict(format_api=True, restrict=restrict) from app.api import tokens diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index 9fd61fdc..a24b0f27 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -53,14 +53,19 @@ def justificatif(justif_id: int = None): "date_fin": "2022-10-31T10:00+01:00", "etat": "valide", "fichier": "archive_id", - "raison": "une raison", + "raison": "une raison", // VIDE si pas le droit "entry_date": "2022-10-31T08:00+01:00", "user_id": 1 or null, } """ - return get_model_api_object(Justificatif, justif_id, Identite) + return get_model_api_object( + Justificatif, + justif_id, + Identite, + restrict=not current_user.has_permission(Permission.AbsJustifView), + ) # etudid @@ -133,8 +138,9 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal # Mise en forme des données puis retour en JSON data_set: list[dict] = [] + restrict = not current_user.has_permission(Permission.AbsJustifView) for just in justificatifs_query.all(): - data = just.to_dict(format_api=True) + data = just.to_dict(format_api=True, restrict=restrict) data_set.append(data) return data_set @@ -172,14 +178,15 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False): justificatifs_query: Query = _filter_manager(request, justificatifs_query) # Mise en forme des données et retour JSON + restrict = not current_user.has_permission(Permission.AbsJustifView) data_set: list[dict] = [] for just in justificatifs_query: - data_set.append(_set_sems(just)) + data_set.append(_set_sems(just, restrict=restrict)) return data_set -def _set_sems(justi: Justificatif) -> dict: +def _set_sems(justi: Justificatif, restrict: bool) -> dict: """ _set_sems Ajoute le formsemestre associé au justificatif s'il existe @@ -192,7 +199,7 @@ def _set_sems(justi: Justificatif) -> dict: dict: La représentation de l'assiduité en dictionnaire """ # Conversion du justificatif en dictionnaire - data = justi.to_dict(format_api=True) + data = justi.to_dict(format_api=True, restrict=restrict) # Récupération du formsemestre de l'assiduité formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict()) @@ -246,9 +253,10 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False): justificatifs_query: Query = _filter_manager(request, justificatifs_query) # Retour des justificatifs en JSON + restrict = not current_user.has_permission(Permission.AbsJustifView) data_set: list[dict] = [] for justi in justificatifs_query.all(): - data = justi.to_dict(format_api=True) + data = justi.to_dict(format_api=True, restrict=restrict) data_set.append(data) return data_set diff --git a/app/models/assiduites.py b/app/models/assiduites.py index c7cf8fa3..b4087e4e 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -88,8 +88,10 @@ class Assiduite(ScoDocModel): lazy="select", ) - def to_dict(self, format_api=True) -> dict: - """Retourne la représentation json de l'assiduité""" + def to_dict(self, format_api=True, restrict: bool | None = None) -> dict: + """Retourne la représentation json de l'assiduité + restrict n'est pas utilisé ici. + """ etat = self.etat user: User | None = None if format_api: @@ -453,8 +455,10 @@ class Justificatif(ScoDocModel): query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) return query.first_or_404() - def to_dict(self, format_api: bool = False) -> dict: - """transformation de l'objet en dictionnaire sérialisable""" + def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict: + """L'objet en dictionnaire sérialisable. + Si restrict, ne donne par la raison et les fichiers et external_data + """ etat = self.etat user: User = self.user if self.user_id is not None else None @@ -469,13 +473,13 @@ class Justificatif(ScoDocModel): "date_debut": self.date_debut, "date_fin": self.date_fin, "etat": etat, - "raison": self.raison, - "fichier": self.fichier, + "raison": None if restrict else self.raison, + "fichier": None if restrict else self.fichier, "entry_date": self.entry_date, "user_id": None if user is None else user.id, # l'uid "user_name": None if user is None else user.user_name, # le login "user_nom_complet": None if user is None else user.get_nomcomplet(), - "external_data": self.external_data, + "external_data": None if restrict else self.external_data, } return data diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 2e06370a..b76a8e77 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -145,7 +145,9 @@ def sco_header( etudid=None, formsemestre_id=None, ): - "Main HTML page header for ScoDoc" + """Main HTML page header for ScoDoc + Utilisé dans les anciennes pages. Les nouvelles pages utilisent le template Jinja. + """ from app.scodoc.sco_formsemestre_status import formsemestre_page_title if etudid is not None: diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index dce59627..c0c732ce 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -32,12 +32,66 @@ from flask import render_template, url_for from flask import g, request from flask_login import current_user +from app import db +from app.models import Evaluation, GroupDescr, ModuleImpl, Partition import app.scodoc.sco_utils as scu from app.scodoc import sco_preferences from app.scodoc.sco_permissions import Permission from sco_version import SCOVERSION +def retreive_formsemestre_from_request() -> int: + """Cherche si on a de quoi déduire le semestre affiché à partir des + arguments de la requête: + formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id + Returns None si pas défini. + """ + if request.method == "GET": + args = request.args + elif request.method == "POST": + args = request.form + else: + return None + formsemestre_id = None + # Search formsemestre + group_ids = args.get("group_ids", []) + if "formsemestre_id" in args: + formsemestre_id = args["formsemestre_id"] + elif "moduleimpl_id" in args and args["moduleimpl_id"]: + modimpl = db.session.get(ModuleImpl, args["moduleimpl_id"]) + if not modimpl: + return None # suppressed ? + formsemestre_id = modimpl.formsemestre_id + elif "evaluation_id" in args: + evaluation = db.session.get(Evaluation, args["evaluation_id"]) + if not evaluation: + return None # evaluation suppressed ? + formsemestre_id = evaluation.moduleimpl.formsemestre_id + elif "group_id" in args: + group = db.session.get(GroupDescr, args["group_id"]) + if not group: + return None + formsemestre_id = group.partition.formsemestre_id + elif group_ids: + if isinstance(group_ids, str): + group_ids = group_ids.split(",") + group_id = group_ids[0] + group = db.session.get(GroupDescr, group_id) + if not group: + return None + formsemestre_id = group.partition.formsemestre_id + elif "partition_id" in args: + partition = db.session.get(Partition, args["partition_id"]) + if not partition: + return None + formsemestre_id = partition.formsemestre_id + + if formsemestre_id is None: + return None # no current formsemestre + + return int(formsemestre_id) + + def sidebar_common(): "partie commune à toutes les sidebar" home_link = url_for("scodoc.index", scodoc_dept=g.scodoc_dept) @@ -129,13 +183,17 @@ def sidebar(etudid: int = None): ) H.append("
      ") if current_user.has_permission(Permission.AbsChange): + # essaie de conserver le semestre actuellement en vue + cur_formsemestre_id = retreive_formsemestre_from_request() H.append( f"""
    • Ajouter
    • Justifier
    • """ ) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 1cea6daf..20d3ec65 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -76,6 +76,7 @@ from app.scodoc import sco_moduleimpl from app.scodoc import sco_preferences from app.scodoc import sco_users from app.scodoc.gen_tables import GenTable +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_formsemestre_custommenu import formsemestre_custommenu_html import sco_version @@ -476,57 +477,6 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: return "\n".join(H) -def retreive_formsemestre_from_request() -> int: - """Cherche si on a de quoi déduire le semestre affiché à partir des - arguments de la requête: - formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id - Returns None si pas défini. - """ - if request.method == "GET": - args = request.args - elif request.method == "POST": - args = request.form - else: - return None - formsemestre_id = None - # Search formsemestre - group_ids = args.get("group_ids", []) - if "formsemestre_id" in args: - formsemestre_id = args["formsemestre_id"] - elif "moduleimpl_id" in args and args["moduleimpl_id"]: - modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=args["moduleimpl_id"]) - if not modimpl: - return None # suppressed ? - modimpl = modimpl[0] - formsemestre_id = modimpl["formsemestre_id"] - elif "evaluation_id" in args: - E = sco_evaluation_db.get_evaluations_dict( - {"evaluation_id": args["evaluation_id"]} - ) - if not E: - return None # evaluation suppressed ? - E = E[0] - modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - formsemestre_id = modimpl["formsemestre_id"] - elif "group_id" in args: - group = sco_groups.get_group(args["group_id"]) - formsemestre_id = group["formsemestre_id"] - elif group_ids: - if isinstance(group_ids, str): - group_ids = group_ids.split(",") - group_id = group_ids[0] - group = sco_groups.get_group(group_id) - formsemestre_id = group["formsemestre_id"] - elif "partition_id" in args: - partition = sco_groups.get_partition(args["partition_id"]) - formsemestre_id = partition["formsemestre_id"] - - if not formsemestre_id: - return None # no current formsemestre - - return int(formsemestre_id) - - # Element HTML decrivant un semestre (barre de menu et infos) def formsemestre_page_title(formsemestre_id=None): """Element HTML decrivant un semestre (barre de menu et infos) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index d34a933f..1f5d572f 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -46,11 +46,11 @@ from app.scodoc import ( sco_bac, sco_cursus, sco_etud, - sco_formsemestre_status, sco_groups, sco_permissions_check, sco_report, ) +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_bulletins import etud_descr_situation_semestre from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table @@ -751,7 +751,7 @@ def etud_info_html(etudid, with_photo="1", debug=False): """An HTML div with basic information and links about this etud. Used for popups information windows. """ - formsemestre_id = sco_formsemestre_status.retreive_formsemestre_from_request() + formsemestre_id = retreive_formsemestre_from_request() with_photo = int(with_photo) etud = Identite.get_etud(etudid) diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index bf871252..6e5e53ee 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -24,7 +24,7 @@ _SCO_PERMISSIONS = ( (1 << 10, "EditAllNotes", "Modifier toutes les notes"), (1 << 11, "EditAllEvals", "Modifier toutes les évaluations"), (1 << 12, "EditFormSemestre", "Mettre en place une formation (créer un semestre)"), - (1 << 13, "AbsChange", "Saisir des absences"), + (1 << 13, "AbsChange", "Saisir des absences ou justificatifs"), (1 << 14, "AbsAddBillet", "Saisir des billets d'absences"), # changer adresse/photo ou pour envoyer bulletins par mail ou pour debouche (1 << 15, "EtudChangeAdr", "Changer les adresses d'étudiants"), @@ -63,7 +63,11 @@ _SCO_PERMISSIONS = ( # # XXX inutilisée ? (1 << 40, "EtudChangePhoto", "Modifier la photo d'un étudiant"), # Permissions du module Assiduité) - (1 << 50, "AbsJustifView", "Visualisation des fichiers justificatifs"), + ( + 1 << 50, + "AbsJustifView", + "Visualisation du détail des justificatifs (motif, fichiers)", + ), # Attention: les permissions sont codées sur 64 bits. ) diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index 24a449b5..e2f93788 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -1,6 +1,7 @@ from datetime import datetime from flask import url_for +from flask_login import current_user from flask_sqlalchemy.query import Query from sqlalchemy import desc, literal, union, asc @@ -10,6 +11,7 @@ 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 +from app.scodoc.sco_permissions import Permission class Pagination: @@ -107,6 +109,11 @@ class ListeAssiJusti(tb.Table): self.total_page: int = None + # Accès aux détail des justificatifs ? + self.can_view_justif_detail = current_user.has_permission( + Permission.AbsJustifView + ) + # les lignes du tableau self.rows: list["RowAssiJusti"] = [] @@ -342,7 +349,7 @@ class RowAssiJusti(tb.Row): # Type d'objet self._type() - # En excel, on export les "vraes dates". + # En excel, on export les "vraies 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() @@ -470,10 +477,21 @@ class RowAssiJusti(tb.Row): def _optionnelles(self) -> None: if self.table.options.show_desc: + if self.ligne.get("type") == "justificatif": + # protection de la "raison" + if ( + self.ligne["user_id"] == current_user.id + or self.table.can_view_justif_detail + ): + description = self.ligne["desc"] if self.ligne["desc"] else "" + else: + description = "(cachée)" + else: + description = self.ligne["desc"] if self.ligne["desc"] else "" self.add_cell( "description", "Description", - self.ligne["desc"] if self.ligne["desc"] else "", + description, ) if self.table.options.show_module: if self.ligne["type"] == "assiduite": diff --git a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 index 61bd3197..27349ba5 100644 --- a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 @@ -17,6 +17,9 @@ form#ajout-justificatif-etud { form#ajout-justificatif-etud > div { margin-bottom: 16px; } +fieldset > div { + margin-bottom: 12px; +} div.fichiers { margin-top: 16px; margin-bottom: 32px; @@ -33,9 +36,20 @@ div.submit { div.submit > input { margin-right: 16px; } +.info-saisie { + margin-top: 12px; + margin-bottom: 12px; + font-style: italic; +}
      -

      Justifier des absences ou retards

      +

      {{title|safe}}

      + + {% if justif %} +
      + Saisie par {{justif.user.get_prenomnom()}} le {{justif.entry_date.strftime("%d/%m/%Y à %H:%M")}} +
      + {% endif %}
      @@ -72,16 +86,24 @@ div.submit > input {
      {# Raison #}
      -
      {{ form.raison.label }}
      - {{ form.raison() }} - {{ render_field_errors(form, 'raison') }} + {% if (not justif) or can_view_justif_detail %} +
      {{ form.raison.label }}
      + {{ form.raison() }} + {{ render_field_errors(form, 'raison') }} +
      La raison sera visible aux utilisateurs ayant le droit + AbsJustifView et à celui ayant déposé le justificatif + {%- if justif %} ({{justif.user.get_prenomnom()}}){%- endif -%}. +
      + {% else %} +
      raison confidentielle
      + {% endif %}
      {# Liste des fichiers existants #} {% if justif and nb_files > 0 %}
      {{nb_files}} fichiers justificatifs déposés {% if filenames|length < nb_files %} - , dont {{filenames|length}} vous sont accessibles + , dont {{filenames|length}} vous {{'sont accessibles' if filenames|length > 1 else 'est accessible'}} {% endif %}
      @@ -104,6 +126,7 @@ div.submit > input { {{ form.entry_date.label }} : {{ form.entry_date }} laisser vide pour date courante {{ render_field_errors(form, 'entry_date') }} + {# Submit #}
      {{ form.submit }} {{ form.cancel }} diff --git a/app/templates/assiduites/pages/tableau_assiduite_actions.j2 b/app/templates/assiduites/pages/tableau_assiduite_actions.j2 index 903fceba..705aaec3 100644 --- a/app/templates/assiduites/pages/tableau_assiduite_actions.j2 +++ b/app/templates/assiduites/pages/tableau_assiduite_actions.j2 @@ -10,11 +10,11 @@ {% if action == "modifier" %} {% include "assiduites/widgets/tableau_actions/modifier.j2" %} -{% else%} +{% else %} {% include "assiduites/widgets/tableau_actions/details.j2" %} {% endif %} -{% if not current_user.has_permission(sco.Permission.AbsJustifView)%} +{% if not current_user.has_permission(sco.Permission.AbsJustifView) %}
      Vous n'avez pas la permission d'ouvrir les fichiers justificatifs déposés par d'autres personnes. @@ -22,7 +22,7 @@ {% endif %} + + + - diff --git a/app/templates/assiduites/pages/liste_assiduites.j2 b/app/templates/assiduites/pages/liste_assiduites.j2 index df630c79..b98a73c3 100644 --- a/app/templates/assiduites/pages/liste_assiduites.j2 +++ b/app/templates/assiduites/pages/liste_assiduites.j2 @@ -1,3 +1,21 @@ +{% extends "sco_page.j2" %} + +{% block title %} +Assiduité de {{etud.nomprenom}} +{% endblock title %} + +{% block styles %} + {{ super() }} + +{% endblock styles %} + +{% block scripts %} + {{ super() }} + + +{% endblock %} + + {% block app_content %}
      diff --git a/app/templates/assiduites/pages/signal_assiduites_diff.j2 b/app/templates/assiduites/pages/signal_assiduites_diff.j2 index d6066a4b..b61b538b 100644 --- a/app/templates/assiduites/pages/signal_assiduites_diff.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_diff.j2 @@ -3,6 +3,26 @@ - TODO : revoir le fonctionnement de cette page (trop lente / complexe) - Utiliser majoritairement du python #} + +{% extends "sco_page.j2" %} + +{% block styles %} + {{ super() }} + +{% endblock styles %} + +{% block title %} + {{title}} +{% endblock title %} + +{% block app_content %} + +{% include "assiduites/widgets/alert.j2" %} +{% include "assiduites/widgets/prompt.j2" %} +{% include "assiduites/widgets/conflict.j2" %} +{% include "assiduites/widgets/toast.j2" %} + +

      Signalement différé de l'assiduité {{gr |safe}}

      Attention, cette page utilise des couleurs et conventions différentes @@ -27,8 +47,14 @@

      Vous pouvez supprimer une colonne en appuyant sur la croix qui se situe dans le coin haut droit de la colonne.

      +{% endblock app_content %} - + + - - - -{% include "assiduites/widgets/alert.j2" %} -{% include "assiduites/widgets/prompt.j2" %} -{% include "assiduites/widgets/conflict.j2" %} -{% include "assiduites/widgets/toast.j2" %} \ No newline at end of file +{% endblock scripts %} diff --git a/app/templates/assiduites/pages/signal_assiduites_group.j2 b/app/templates/assiduites/pages/signal_assiduites_group.j2 index 2ce3672e..1d66a3c3 100644 --- a/app/templates/assiduites/pages/signal_assiduites_group.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_group.j2 @@ -1,4 +1,86 @@ +{% extends "sco_page.j2" %} + +{% block title %} + {{title}} +{% endblock title %} + + +{% block scripts %} + {{ super() }} + + + + + + + + +{% endblock scripts %} + +{% block styles %} + {{ super() }} + + + + + +{% endblock styles %} + + +{% block app_content %} {% include "assiduites/widgets/toast.j2" %} + +{{ minitimeline|safe }} +
      @@ -78,55 +160,6 @@ {% include "assiduites/widgets/prompt.j2" %} {% include "assiduites/widgets/conflict.j2" %} - -
      \ No newline at end of file +{% endblock app_content %} diff --git a/app/templates/assiduites/widgets/differee.j2 b/app/templates/assiduites/widgets/differee.j2 index 250545c2..aebf1b98 100644 --- a/app/templates/assiduites/widgets/differee.j2 +++ b/app/templates/assiduites/widgets/differee.j2 @@ -270,6 +270,10 @@ -webkit-box-sizing: border-box; border: 10px solid white; } + + .mini-form { + color: black; + } - + + {%- endblock scripts %} {%- endblock body %} diff --git a/app/templates/base.j2 b/app/templates/base.j2 index 70462e26..a75325e5 100644 --- a/app/templates/base.j2 +++ b/app/templates/base.j2 @@ -1,4 +1,4 @@ -{# -*- mode: jinja-html -*- #} +{# base des pages hors départements (accueil, configuration, ...) #} {% extends 'babase.j2' %} {% block styles %} @@ -103,6 +103,5 @@ {% endblock %} \ No newline at end of file diff --git a/app/templates/sco_page.j2 b/app/templates/sco_page.j2 index f2bc9425..f50bd4cb 100644 --- a/app/templates/sco_page.j2 +++ b/app/templates/sco_page.j2 @@ -1,4 +1,4 @@ -{# -*- mode: jinja-html -*- #} +{# -*- Base des pages ordinaires, dans départements -*- #} {% extends 'babase.j2' %} {% block styles %} diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 5a72ebf0..8e58de88 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -85,85 +85,6 @@ from app.scodoc.sco_archives_justificatifs import JustificatifArchiver CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS -# --- UTILS --- - - -class HTMLElement: - """Représentation d'un HTMLElement version Python""" - - def __init__(self, tag: str, *attr, **kattr) -> None: - self.tag: str = tag - self.children: list["HTMLElement"] = [] - self.self_close: bool = kattr.get("self_close", False) - self.text_content: str = kattr.get("text_content", "") - self.key_attributes: dict[str, Any] = kattr - self.attributes: list[str] = list(attr) - - def add(self, *child: "HTMLElement") -> None: - """add child element to self""" - for kid in child: - self.children.append(kid) - - def remove(self, child: "HTMLElement") -> None: - """Remove child element from self""" - if child in self.children: - self.children.remove(child) - - def __str__(self) -> str: - attr: list[str] = self.attributes - - for att, val in self.key_attributes.items(): - if att in ("self_close", "text_content"): - continue - - if att != "cls": - attr.append(f'{att}="{val}"') - else: - attr.append(f'class="{val}"') - - if not self.self_close: - head: str = f"<{self.tag} {' '.join(attr)}>{self.text_content}" - body: str = "\n".join(map(str, self.children)) - foot: str = f"" - return head + body + foot - return f"<{self.tag} {' '.join(attr)}/>" - - def __add__(self, other: str): - return str(self) + other - - def __radd__(self, other: str): - return other + str(self) - - -class HTMLStringElement(HTMLElement): - """Utilisation d'une chaine de caracètres pour représenter un element""" - - def __init__(self, text: str) -> None: - self.text: str = text - HTMLElement.__init__(self, "textnode") - - def __str__(self) -> str: - return self.text - - -class HTMLBuilder: - def __init__(self, *content: HTMLElement | str) -> None: - self.content: list[HTMLElement | str] = list(content) - - def add(self, *element: HTMLElement | str): - self.content.extend(element) - - def remove(self, element: HTMLElement | str): - if element in self.content: - self.content.remove(element) - - def __str__(self) -> str: - return "\n".join(map(str, self.content)) - - def build(self) -> str: - return self.__str__() - - # -------------------------------------------------------------------- # # Assiduité (/ScoDoc//Scolarite/Assiduites/...) @@ -539,18 +460,6 @@ def liste_assiduites_etud(): assiduite_id: int = request.args.get("assiduite_id", -1) # Préparation de la page - header: str = html_sco_header.sco_header( - page_title=f"Assiduité de {etud.nomprenom}", - init_qtip=True, - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) tableau = _prepare_tableau( liste_assi.AssiJustifData.from_etudiants( etud, @@ -563,16 +472,14 @@ def liste_assiduites_etud(): ) if not tableau[0]: return tableau[1] - # Peuplement du template jinja - return HTMLBuilder( - header, - render_template( - "assiduites/pages/liste_assiduites.j2", - sco=ScoData(etud), - assi_id=assiduite_id, - tableau=tableau[1], - ), - ).build() + # Page HTML: + return render_template( + "assiduites/pages/liste_assiduites.j2", + assi_id=assiduite_id, + etud=etud, + tableau=tableau[1], + sco=ScoData(etud), + ) @bp.route("/bilan_etud") @@ -593,20 +500,6 @@ def bilan_etud(): if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") - # Préparation de la page (header) - header: str = html_sco_header.sco_header( - page_title=f"Bilan de l'assiduité de {etud.nomprenom}", - init_qtip=True, - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) - # Gestion des dates du bilan (par défaut l'année scolaire) date_debut = scu.date_debut_annee_scolaire().strftime("%d/%m/%Y") date_fin: str = scu.date_fin_annee_scolaire().strftime("%d/%m/%Y") @@ -639,23 +532,20 @@ def bilan_etud(): if not table[0]: return table[1] - # Génération de la page - return HTMLBuilder( - header, - render_template( - "assiduites/pages/bilan_etud.j2", - sco=ScoData(etud), - date_debut=date_debut, - date_fin=date_fin, - assi_metric=assi_metric, - assi_seuil=_get_seuil(), - assi_limit_annee=sco_preferences.get_preference( - "assi_limit_annee", - dept_id=g.scodoc_dept_id, - ), - tableau=table[1], + # Génération de la page HTML + return render_template( + "assiduites/pages/bilan_etud.j2", + assi_limit_annee=sco_preferences.get_preference( + "assi_limit_annee", + dept_id=g.scodoc_dept_id, ), - ).build() + assi_metric=assi_metric, + assi_seuil=_get_seuil(), + date_debut=date_debut, + date_fin=date_fin, + sco=ScoData(etud), + tableau=table[1], + ) @bp.route("/edit_justificatif_etud/", methods=["GET", "POST"]) @@ -1105,55 +995,33 @@ def signal_assiduites_group(): grp + ' ' + groups_infos.groups_titles + "" ) - # --- Génération de l'HTML --- - - header: str = html_sco_header.sco_header( - page_title="Saisie journalière des assiduités", - init_qtip=True, - javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS - + [ - # Voir fonctionnement JS - "js/etud_info.js", - "js/groups_view.js", - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - "css/minitimeline.css", - ], - ) - # Récupération du semestre en dictionnaire sem = formsemestre.to_dict() - # Peuplement du template jinja - return HTMLBuilder( - header, - _mini_timeline(), - render_template( - "assiduites/pages/signal_assiduites_group.j2", - gr_tit=gr_tit, - sem=sem["titre_num"], - date=_dateiso_to_datefr(date), + # Page HTML + return render_template( + "assiduites/pages/signal_assiduites_group.j2", + date=_dateiso_to_datefr(date), + defdem=_get_etuds_dem_def(formsemestre), + forcer_module=sco_preferences.get_preference( + "forcer_module", formsemestre_id=formsemestre_id, - grp=sco_groups_view.menu_groups_choice(groups_infos), - moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), - timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), - nonworkdays=_non_work_days(), - formsemestre_date_debut=str(formsemestre.date_debut), - formsemestre_date_fin=str(formsemestre.date_fin), - forcer_module=sco_preferences.get_preference( - "forcer_module", - formsemestre_id=formsemestre_id, - dept_id=g.scodoc_dept_id, - ), - defdem=_get_etuds_dem_def(formsemestre), - readonly="false", + dept_id=g.scodoc_dept_id, ), - html_sco_header.sco_footer(), - ).build() + formsemestre_date_debut=str(formsemestre.date_debut), + formsemestre_date_fin=str(formsemestre.date_fin), + formsemestre_id=formsemestre_id, + gr_tit=gr_tit, + grp=sco_groups_view.menu_groups_choice(groups_infos), + minitimeline=_mini_timeline(), + moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), + nonworkdays=_non_work_days(), + readonly="false", + sco=ScoData(formsemestre=formsemestre), + sem=sem["titre_num"], + timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), + title="Saisie journalière des assiduités", + ) @bp.route("/visu_assiduites_group") @@ -1248,7 +1116,7 @@ def visu_assiduites_group(): # Si aucun etudiant n'est inscrit au module choisi... moduleimpl_id = None - # --- Génération de l'HTML --- + # --- Génération du HTML --- if groups_infos.tous_les_etuds_du_sem: gr_tit = "en" @@ -1261,52 +1129,32 @@ def visu_assiduites_group(): grp + ' ' + groups_infos.groups_titles + "" ) - header: str = html_sco_header.sco_header( - page_title="Saisie journalière de l'assiduité", - init_qtip=True, - javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS - + [ - # Voir fonctionnement JS - "js/etud_info.js", - "js/groups_view.js", - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - "css/minitimeline.css", - ], - ) - # Récupération du semestre en dictionnaire sem = formsemestre.to_dict() - return HTMLBuilder( - header, - _mini_timeline(), - render_template( - "assiduites/pages/signal_assiduites_group.j2", - gr_tit=gr_tit, - sem=sem["titre_num"], - date=_dateiso_to_datefr(date), + return render_template( + "assiduites/pages/signal_assiduites_group.j2", + date=_dateiso_to_datefr(date), + defdem=_get_etuds_dem_def(formsemestre), + forcer_module=sco_preferences.get_preference( + "forcer_module", formsemestre_id=formsemestre_id, - grp=sco_groups_view.menu_groups_choice(groups_infos), - moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), - timeline=_timeline(), - nonworkdays=_non_work_days(), - formsemestre_date_debut=str(formsemestre.date_debut), - formsemestre_date_fin=str(formsemestre.date_fin), - forcer_module=sco_preferences.get_preference( - "forcer_module", - formsemestre_id=formsemestre_id, - dept_id=g.scodoc_dept_id, - ), - defdem=_get_etuds_dem_def(formsemestre), - readonly="true", + dept_id=g.scodoc_dept_id, ), - html_sco_header.sco_footer(), - ).build() + formsemestre_date_debut=str(formsemestre.date_debut), + formsemestre_date_fin=str(formsemestre.date_fin), + formsemestre_id=formsemestre_id, + gr_tit=gr_tit, + grp=sco_groups_view.menu_groups_choice(groups_infos), + minitimeline=_mini_timeline(), + moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), + nonworkdays=_non_work_days(), + sem=sem["titre_num"], + timeline=_timeline(), + readonly="true", + sco=ScoData(formsemestre=formsemestre), + title="Saisie journalière de l'assiduité", + ) class RowEtudWithAssi(RowEtud): @@ -1828,7 +1676,9 @@ def _preparer_objet( @scodoc @permission_required(Permission.AbsChange) def signal_assiduites_diff(): - """TODO documenter""" + """TODO documenter + Utilisé notamment par "Saisie différée" sur tableau de bord semetstre" + """ # Récupération des paramètres de la requête group_ids: list[int] = request.args.get("group_ids", None) formsemestre_id: int = request.args.get("formsemestre_id", -1) @@ -1910,32 +1760,29 @@ def signal_assiduites_diff(): grp + ' ' + groups_infos.groups_titles + "" ) - return HTMLBuilder( - header, - render_template( - "assiduites/pages/signal_assiduites_diff.j2", - diff=_differee( - etudiants=etudiants, - moduleimpl_select=_module_selector( - formsemestre, request.args.get("moduleimpl_id", None) - ), - date=date, - periode={ - "deb": formsemestre.date_debut.isoformat(), - "fin": formsemestre.date_fin.isoformat(), - }, + return render_template( + "assiduites/pages/signal_assiduites_diff.j2", + defaultDates=_get_days_between_dates(date_deb, date_fin), + defdem=_get_etuds_dem_def(formsemestre), + diff=_differee( + etudiants=etudiants, + moduleimpl_select=_module_selector( + formsemestre, request.args.get("moduleimpl_id", None) ), - gr=gr_tit, - sem=formsemestre.titre_num(), - defdem=_get_etuds_dem_def(formsemestre), - timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"), - timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"), - timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"), - defaultDates=_get_days_between_dates(date_deb, date_fin), - nonworkdays=_non_work_days(), + date=date, + periode={ + "deb": formsemestre.date_debut.isoformat(), + "fin": formsemestre.date_fin.isoformat(), + }, ), - html_sco_header.sco_footer(), - ).build() + gr=gr_tit, + nonworkdays=_non_work_days(), + sco=ScoData(formsemestre=formsemestre), + sem=formsemestre.titre_num(), + timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"), + timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"), + timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"), + ) @bp.route("/signale_evaluation_abs//") From f842fa0b4fd3b11f2ee097bdfe02624420e24de7 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 21 Jan 2024 22:30:10 +0100 Subject: [PATCH 38/55] typo + TODO #850 --- app/static/js/assiduites.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 93f98ba4..8141dec3 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -720,6 +720,7 @@ function setupDate(onchange = null) { //Initialisation du datepicker // sinon on ne peut pas le mettre à jour + // XXX TODO-assiduite : finir tester + éviter duplication code avec scodoc.js $(input).datepicker({ showOn: "button", buttonImage: "/ScoDoc/static/icons/calendar_img.png", @@ -745,7 +746,7 @@ function setupDate(onchange = null) { "Avril", "May", "Juin", - "Juilet", + "Juillet", "Août", "Septembre", "Octobre", From dae04658b767b9a7194f9d8dda8823de9d91f77c Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 21 Jan 2024 23:15:44 +0100 Subject: [PATCH 39/55] Fix: bug assiduites stats --- app/scodoc/sco_assiduites.py | 10 +++++++++- app/tables/visu_assiduites.py | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 6b65b279..fd309866 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -170,7 +170,7 @@ class CountCalculator: """Récupère une clé de dictionnaire en fonction de l'état de l'assiduité et si elle est justifié """ - keys: dict[EtatAssiduite, str] = { + keys: dict[scu.EtatAssiduite, str] = { scu.EtatAssiduite.ABSENT: "absent", scu.EtatAssiduite.RETARD: "retard", scu.EtatAssiduite.PRESENT: "present", @@ -349,6 +349,11 @@ def get_assiduites_stats( assiduites: Query, metric: str = "all", filtered: dict[str, object] = None ) -> dict[str, int | float]: """Compte les assiduités en fonction des filtres""" + # XXX TODO-assiduite : documenter !!! + # Que sont les filtres ? Quelles valeurs ? + # documenter permet de faire moins de bug: qualité du code non satisfaisante. + # + # + on se perd entre les clés en majuscules et en minuscules. Pourquoi if filtered is not None: deb, fin = None, None @@ -399,6 +404,9 @@ def get_assiduites_stats( # Préparation du dictionnaire de retour avec les valeurs du calcul count: dict = calculator.to_dict(only_total=False) for etat in etats: + # TODO-assiduite: on se perd entre les lower et upper. + # Pourquoi EtatAssiduite est en majuscules si tout le reste est en minuscules ? + etat = etat.lower() if etat != "present": output[etat] = count[etat] output[etat]["justifie"] = count[etat + "_just"] diff --git a/app/tables/visu_assiduites.py b/app/tables/visu_assiduites.py index ed37c287..f9acbd83 100644 --- a/app/tables/visu_assiduites.py +++ b/app/tables/visu_assiduites.py @@ -162,7 +162,7 @@ class RowAssi(tb.Row): }, ) - # Pour chaque état on mets à jour les valeurs de retour + # Pour chaque état on met à jour les valeurs de retour for etat, valeur in retour.items(): valeur[1] = compte_etat[etat][assi_metric] if etat != "present": From e415a5255e7f9ca6212e9f461251d601c4a685a8 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Jan 2024 09:57:41 +0100 Subject: [PATCH 40/55] Fix bug: evaluations sans dates --- app/models/evaluations.py | 2 +- sco_version.py | 2 +- tests/api/setup_test_api.py | 38 +++++++++++++++++++++++++------------ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 0c7d1213..b5ff3a67 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -184,7 +184,7 @@ class Evaluation(db.Model): # ScoDoc7 output_formators e_dict["evaluation_id"] = self.id e_dict["date_debut"] = self.date_debut.isoformat() if self.date_debut else None - e_dict["date_fin"] = self.date_debut.isoformat() if self.date_fin else None + e_dict["date_fin"] = self.date_fin.isoformat() if self.date_fin else None e_dict["numero"] = self.numero or 0 e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids } diff --git a/sco_version.py b/sco_version.py index 98421beb..f683bf52 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.80" +SCOVERSION = "9.6.81" SCONAME = "ScoDoc" diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py index c34867d1..f564b9d1 100644 --- a/tests/api/setup_test_api.py +++ b/tests/api/setup_test_api.py @@ -15,8 +15,18 @@ Utilisation : """ import os import requests -from dotenv import load_dotenv -import pytest + +try: + from dotenv import load_dotenv +except ModuleNotFoundError: + print("\nWarning: dotenv not installed, ignoring .env") + print("You may install it using:\npip install python-dotenv\n") + load_dotenv = None +try: + import pytest +except ModuleNotFoundError: + print("pytest not installed\n") + pytest = None # --- Lecture configuration (variables d'env ou .env) try: @@ -24,9 +34,11 @@ try: except NameError: BASEDIR = "/opt/scodoc/tests/api" -load_dotenv(os.path.join(BASEDIR, ".env")) +if load_dotenv: + load_dotenv(os.path.join(BASEDIR, ".env")) + CHECK_CERTIFICATE = bool(os.environ.get("CHECK_CERTIFICATE", False)) -SCODOC_URL = os.environ["SCODOC_URL"] or "http://localhost:5000" +SCODOC_URL = os.environ.get("SCODOC_URL") or "http://localhost:5000" API_URL = SCODOC_URL + "/ScoDoc/api" API_USER = os.environ.get("API_USER", "test") API_PASSWORD = os.environ.get("API_PASSWORD", os.environ.get("API_PASSWD", "test")) @@ -36,6 +48,7 @@ DEPT_ACRONYM = "TAPI" SCO_TEST_API_TIMEOUT = 5 print(f"SCODOC_URL={SCODOC_URL}") print(f"API URL={API_URL}") +print(f"API_USER={API_USER}") class APIError(Exception): @@ -53,16 +66,17 @@ def get_auth_headers(user, password) -> dict: return {"Authorization": f"Bearer {token}"} -@pytest.fixture -def api_headers() -> dict: - """Jeton, utilisateur API ordinaire""" - return get_auth_headers(API_USER, API_PASSWORD) +if pytest: + @pytest.fixture + def api_headers() -> dict: + """Jeton, utilisateur API ordinaire""" + return get_auth_headers(API_USER, API_PASSWORD) -@pytest.fixture -def api_admin_headers() -> dict: - """Jeton, utilisateur API SuperAdmin""" - return get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN) + @pytest.fixture + def api_admin_headers() -> dict: + """Jeton, utilisateur API SuperAdmin""" + return get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN) def GET(path: str, headers: dict = None, errmsg=None, dept=None): From 2660801dd5faaf099ac10724f3d1894cfbe1611f Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Jan 2024 13:15:24 +0100 Subject: [PATCH 41/55] API: script exemple: exemple-api-list-modules.p --- sco_version.py | 2 +- tests/api/exemple-api-list-modules.py | 99 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/api/exemple-api-list-modules.py diff --git a/sco_version.py b/sco_version.py index f683bf52..6e2ea320 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.81" +SCOVERSION = "9.6.82" SCONAME = "ScoDoc" diff --git a/tests/api/exemple-api-list-modules.py b/tests/api/exemple-api-list-modules.py new file mode 100644 index 00000000..2bcd54f2 --- /dev/null +++ b/tests/api/exemple-api-list-modules.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +"""Exemple utilisation API ScoDoc 9 avec jeton obtenu par basic authentication + + Extraction de la liste de tous les modules d'une année scolaire + + Usage: + cd /opt/scodoc/tests/api + python -i exemple-api-list-modules.py + + +Pour utiliser l'API, (sur une base quelconque): +``` +cd /opt/scodoc/tests/api + +python -i exemple-api-list-modules.py +>>> admin_h = get_auth_headers("admin", "xxx") +>>> GET("/etudiant/etudid/14806", headers=admin_h) +``` + +Créer éventuellement un fichier `.env` dans /opt/scodoc/tests/api +avec la config du client API: +``` + SCODOC_URL = "http://localhost:5000/" + API_USER = "admin" + API_PASSWORD = "test" +``` +""" + +from pprint import pprint as pp +import requests +import sys +import urllib3 +from setup_test_api import ( + API_PASSWORD, # lus de l'environnement ou du .env + API_URL, + API_USER, + APIError, + CHECK_CERTIFICATE, + get_auth_headers, + GET, + POST_JSON, + SCODOC_URL, +) + + +def logout_api_user(): + r = requests.delete(API_URL + "/tokens", headers=HEADERS, verify=CHECK_CERTIFICATE) + assert r.status_code == 204 + + +if not CHECK_CERTIFICATE: + urllib3.disable_warnings() + +# Si vous n'utilisez pas .env: +API_USER = "lecteur_api" +API_PASSWORD = "azerty" + +HEADERS = get_auth_headers(API_USER, API_PASSWORD) +print("connected to ScoDoc") + +# Liste des formsemestres de l'année scolaire +ANNEE_SCOLAIRE = 2023 # int, année de début de l'année scolaire + +formsemestres = GET("/formsemestres/query?annee_scolaire=2023", headers=HEADERS) +print(f"Nombre de semestres: {len(formsemestres)}") + +r = [] # liste de dict, résultat +for formsemestre in formsemestres: + print(f"requesting {formsemestre['titre_num']}") + programme = GET(f"/formsemestre/{formsemestre['id']}/programme", headers=HEADERS) + for mod_type in ("ressources", "saes", "modules"): + mods = programme[mod_type] + for mod in mods: + r.append( + { + "dept": formsemestre["departement"]["acronym"], + "sem_id": formsemestre["id"], + "sem_titre": formsemestre["titre"], + "sem_modalite": formsemestre["modalite"], + "sem_etape": formsemestre["etape_apo"], + "mod_type": mod_type[:-1], + "mod_code": mod["module"]["code"], + "mod_titre": mod["module"]["titre"], + "mod_abbrev": mod["module"]["abbrev"], + "mod_code_apogee": mod["module"]["code_apogee"], + "modimpl_code_apogee": mod["code_apogee"], + } + ) + +# Dump to csv file +SEP = "\t" +with open("/tmp/modules.csv", "w", encoding="utf-8") as f: + f.write(SEP.join(r[0]) + "\n") + for l in r: + # on élimine les éventuels séparateurs des champs... + f.write(SEP.join([str(x).replace(SEP, " ") for x in l.values()]) + "\n") From a7848f0a4e9f465ec0b45028e68f9a71cb03df1e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Jan 2024 13:18:54 +0100 Subject: [PATCH 42/55] typo --- tests/api/exemple-api-list-modules.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/api/exemple-api-list-modules.py b/tests/api/exemple-api-list-modules.py index 2bcd54f2..2cc14659 100644 --- a/tests/api/exemple-api-list-modules.py +++ b/tests/api/exemple-api-list-modules.py @@ -64,7 +64,9 @@ print("connected to ScoDoc") # Liste des formsemestres de l'année scolaire ANNEE_SCOLAIRE = 2023 # int, année de début de l'année scolaire -formsemestres = GET("/formsemestres/query?annee_scolaire=2023", headers=HEADERS) +formsemestres = GET( + f"/formsemestres/query?annee_scolaire={ANNEE_SCOLAIRE}", headers=HEADERS +) print(f"Nombre de semestres: {len(formsemestres)}") r = [] # liste de dict, résultat From 505f5e5f1c2e0aceaf831eef52ce7667c3325889 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Jan 2024 13:35:59 +0100 Subject: [PATCH 43/55] Fix yet another bug on evaluations dates --- app/scodoc/sco_evaluations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index 22aa43eb..382f883d 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -691,7 +691,7 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True) group_ids=group_id, evaluation_id=evaluation.id, date_debut=evaluation.date_debut.isoformat(), - date_fin=evaluation.date_fin.isoformat(), + date_fin=evaluation.date_fin.isoformat() if evaluation.date_fin else "", ) }">absences ce jour From 2ad77428a52a6aa854c372da7ede4d35222f6a96 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Jan 2024 13:38:04 +0100 Subject: [PATCH 44/55] =?UTF-8?q?Fix:=20d=C3=A9sactive=20etat=5Fabs=5Fdate?= =?UTF-8?q?=20si=20=C3=A9valuation=20sans=20date=20de=20fin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_moduleimpl_status.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 5f01a3bf..e97716b6 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -134,7 +134,8 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str: if evaluation.date_fin else "", }, - "enabled": evaluation.date_debut is not None, + "enabled": evaluation.date_debut is not None + and evaluation.date_fin is not None, }, { "title": "Vérifier notes vs absents", From 74b8b90a651f0c354dd26c53915c688e55ced45b Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Jan 2024 15:50:01 +0100 Subject: [PATCH 45/55] Fix: retreive_formsemestre_from_request / sans semestre --- app/scodoc/html_sidebar.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index c0c732ce..b64cefc6 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -88,8 +88,10 @@ def retreive_formsemestre_from_request() -> int: if formsemestre_id is None: return None # no current formsemestre - - return int(formsemestre_id) + try: + return int(formsemestre_id) + except ValueError: + return None # no current formsemestre def sidebar_common(): From 9989f419cb9eb48ccc21ef9346fa3b3874cc7204 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Jan 2024 16:30:18 +0100 Subject: [PATCH 46/55] Fix: 2 typos --- app/scodoc/sco_page_etud.py | 1 + app/scodoc/sco_report.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 1f5d572f..2a317add 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -218,6 +218,7 @@ def fiche_etud(etudid=None): only_to_show=True, ) # Parcours de l'étudiant + last_formsemestre = None inscriptions = etud.inscriptions() info["last_formsemestre_id"] = ( inscriptions[0].formsemestre.id if inscriptions else "" diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index 769c202f..75caad14 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -1115,7 +1115,7 @@ def get_code_cursus_etud( p.append(":R") if ( dec - and s["semestre_id"] == nt.parcours.NB_SEM + and formsemestre.semestre_id == nt.parcours.NB_SEM and code_semestre_validant(dec["code"]) ): p.append(":A") From 2a239ab92f49fc2fe57a57a5821338cfc898dd3e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Jan 2024 17:01:01 +0100 Subject: [PATCH 47/55] Assiduites: 3 bugs --- app/models/assiduites.py | 6 ++++-- .../assiduites/pages/ajout_justificatif_etud.j2 | 3 ++- app/views/assiduites.py | 11 ++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index b4087e4e..620b2758 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -2,8 +2,10 @@ """Gestion de l'assiduité (assiduités + justificatifs) """ from datetime import datetime + from flask_login import current_user from flask_sqlalchemy.query import Query +from psycopg2.errors import InvalidTextRepresentation # c'est ok from sqlalchemy.exc import DataError from app import db, log, g, set_sco_dept @@ -285,11 +287,11 @@ class Assiduite(ScoDocModel): else: raise ScoValueError("L'étudiant n'est pas inscrit au module") - except DataError: + except (DataError, InvalidTextRepresentation) as exc: # On arrive ici si moduleimpl_id == "autre" ou moduleimpl_id == non parsé if moduleimpl_id != "autre": - raise ScoValueError("Module non reconnu") + raise ScoValueError("Module non reconnu") from exc # Configuration de external_data pour Module Autre # Si self.external_data None alors on créé un dictionnaire {"module": "autre"} diff --git a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 index 27349ba5..3aee67d0 100644 --- a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 @@ -47,7 +47,8 @@ div.submit > input { {% if justif %}
      - Saisie par {{justif.user.get_prenomnom()}} le {{justif.entry_date.strftime("%d/%m/%Y à %H:%M")}} + Saisie par {{justif.user.get_prenomnom() if justif.user else "inconnu"}} + le {{justif.entry_date.strftime("%d/%m/%Y à %H:%M") if justif.entry_date else "?"}}
      {% endif %} diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 8e58de88..07d8a691 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -1247,11 +1247,12 @@ def etat_abs_date(): # On récupère l'état de la première assiduité sur la période assi = assiduites.filter_by(etudid=etud.id).first() etat = "" - if assi is not None and assi.etat != scu.EtatAssiduite.PRESENT: - etat = scu.EtatAssiduite.inverse().get(assi.etat).name - row = table.row_class(table, etud, etat, assi.est_just) - row.add_etud_cols() - table.add_row(row) + if assi is not None: + if assi.etat != scu.EtatAssiduite.PRESENT: + etat = scu.EtatAssiduite.inverse().get(assi.etat).name + row = table.row_class(table, etud, etat, assi.est_just) + row.add_etud_cols() + table.add_row(row) if fmt.startswith("xls"): return scu.send_file( From dfbe0dc3eda0dd542fa4784a2ff95d20f67a7b9d Mon Sep 17 00:00:00 2001 From: Iziram Date: Tue, 23 Jan 2024 09:14:38 +0100 Subject: [PATCH 48/55] =?UTF-8?q?Assiduites=20:=20fix=20bug=20modif=20just?= =?UTF-8?q?if=20(signal=C3=A9=20par=20Sebastien)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/assiduites.py | 5 +++++ app/views/assiduites.py | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 620b2758..9c110992 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -629,6 +629,11 @@ def compute_assiduites_justified( list[int]: la liste des assiduités qui ont été justifiées. """ # TODO à optimiser (car très long avec 40000 assiduités) + # On devrait : + # - récupérer uniquement les assiduités qui sont sur la période des justificatifs donnés + # - Pour chaque assiduité trouvée, il faut récupérer les justificatifs qui la justifie + # - Si au moins un justificatif valide couvre la période de l'assiduité alors on la justifie + # 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/views/assiduites.py b/app/views/assiduites.py index 07d8a691..8015f1e4 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -726,7 +726,12 @@ def _record_justificatif_etud( db.session.rollback() return False db.session.commit() - compute_assiduites_justified(etud.id, [justif]) + # FIX TEMPORAIRE: + # on reprend toutes les assiduités et tous les justificatifs + # pour utiliser le "reset" (remise en "non_just") des assiduités + # (à terme, il faudrait ne recalculer que les assiduités impactées) + # VOIR TODO dans compute_assiduites_justified + compute_assiduites_justified(etud.id, reset=True) scass.simple_invalidate_cache(justif.to_dict(), etud.id) flash(message) return True From 086b8ee19179a9f62f0c39e31a0456d23fcf40ea Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 23 Jan 2024 20:09:13 +0100 Subject: [PATCH 49/55] Assiduite: un peu de nettoyage, corrections --- app/models/assiduites.py | 83 ++-- app/scodoc/sco_moduleimpl_inscriptions.py | 4 +- app/scodoc/sco_preferences.py | 1 + app/scodoc/sco_utils.py | 16 +- .../assiduites/pages/ajout_assiduites.j2 | 235 --------- .../assiduites/pages/liste_semestre.j2 | 94 ---- .../pages/signal_assiduites_etud.j2 | 160 ------ .../widgets/moduleimpl_dynamic_selector.j2 | 156 ------ .../widgets/simplemoduleimpl_select.j2 | 13 +- .../assiduites/widgets/tableau_assi.j2 | 465 ------------------ app/views/assiduites.py | 21 +- sco_version.py | 2 +- 12 files changed, 63 insertions(+), 1187 deletions(-) delete mode 100644 app/templates/assiduites/pages/ajout_assiduites.j2 delete mode 100644 app/templates/assiduites/pages/liste_semestre.j2 delete mode 100644 app/templates/assiduites/pages/signal_assiduites_etud.j2 delete mode 100644 app/templates/assiduites/widgets/moduleimpl_dynamic_selector.j2 delete mode 100644 app/templates/assiduites/widgets/tableau_assi.j2 diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 620b2758..5d59c07f 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -5,7 +5,6 @@ from datetime import datetime from flask_login import current_user from flask_sqlalchemy.query import Query -from psycopg2.errors import InvalidTextRepresentation # c'est ok from sqlalchemy.exc import DataError from app import db, log, g, set_sco_dept @@ -256,43 +255,19 @@ class Assiduite(ScoDocModel): def set_moduleimpl(self, moduleimpl_id: int | str): """Mise à jour du moduleimpl_id - Les valeurs du champs "moduleimpl_id" possibles sont : + Les valeurs du champ "moduleimpl_id" possibles sont : - (un id classique) - ("autre" ou "") - - None (pas de moduleimpl_id) + - "" (pas de moduleimpl_id) Si la valeur est "autre" il faut: - mettre à None assiduité.moduleimpl_id - mettre à jour assiduite.external_data["module"] = "autre" - En fonction de la configuration du semestre la valeur `None` peut-être considérée comme invalide. + En fonction de la configuration du semestre (option force_module) la valeur "" peut-être + considérée comme invalide. - Il faudra donc vérifier que ce n'est pas le cas avant de mettre à jour l'assiduité """ moduleimpl: ModuleImpl = None - try: - # ne lève une erreur que si moduleimpl_id est une chaine de caractère non parsable (parseInt) - moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) - # moduleImpl est soit : - # - None si moduleimpl_id==None - # - None si moduleimpl_id== non reconnu - # - ModuleImpl si valide - - # Vérification ModuleImpl not None (raise ScoValueError) - if moduleimpl is None and self._check_force_module(moduleimpl): - # Ici uniquement si on est autorisé à ne pas avoir de module - self.moduleimpl_id = None - return - - # Vérification Inscription ModuleImpl (raise ScoValueError) - if moduleimpl.est_inscrit(self.etudiant): - self.moduleimpl_id = moduleimpl.id - else: - raise ScoValueError("L'étudiant n'est pas inscrit au module") - - except (DataError, InvalidTextRepresentation) as exc: - # On arrive ici si moduleimpl_id == "autre" ou moduleimpl_id == non parsé - - if moduleimpl_id != "autre": - raise ScoValueError("Module non reconnu") from exc - + if moduleimpl_id == "autre": # Configuration de external_data pour Module Autre # Si self.external_data None alors on créé un dictionnaire {"module": "autre"} # Sinon on met à jour external_data["module"] à "autre" @@ -306,6 +281,29 @@ class Assiduite(ScoDocModel): self.moduleimpl_id = None # Ici pas de vérification du force module car on l'a mis dans "external_data" + return + + if moduleimpl_id != "": + try: + moduleimpl_id = int(moduleimpl_id) + except ValueError as exc: + raise ScoValueError("Module non reconnu") from exc + moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) + + # ici moduleimpl est None si non spécifié + + # Vérification ModuleImpl not None (raise ScoValueError) + if moduleimpl is None: + self._check_force_module() + # Ici uniquement si on est autorisé à ne pas avoir de module + self.moduleimpl_id = None + return + + # Vérification Inscription ModuleImpl (raise ScoValueError) + if moduleimpl.est_inscrit(self.etudiant): + self.moduleimpl_id = moduleimpl.id + else: + raise ScoValueError("L'étudiant n'est pas inscrit au module") def supprime(self): "Supprime l'assiduité. Log et commit." @@ -335,7 +333,7 @@ class Assiduite(ScoDocModel): return get_formsemestre_from_data(self.to_dict()) def get_module(self, traduire: bool = False) -> int | str: - "TODO" + "TODO documenter" if self.moduleimpl_id is not None: if traduire: modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id) @@ -364,8 +362,12 @@ class Assiduite(ScoDocModel): return f"saisie le {date} {utilisateur}" - def _check_force_module(self, moduleimpl: ModuleImpl) -> bool: - # Vérification si module forcé + def _check_force_module(self): + """Vérification si module forcé: + Si le module est requis, raise ScoValueError + sinon ne fait rien. + """ + # cherche le formsemestre affecté pour utiliser ses préférences formsemestre: FormSemestre = get_formsemestre_from_data( { "etudid": self.etudid, @@ -373,18 +375,15 @@ class Assiduite(ScoDocModel): "date_fin": self.date_fin, } ) - force: bool - - if formsemestre: - force = is_assiduites_module_forced(formsemestre_id=formsemestre.id) - else: - force = is_assiduites_module_forced(dept_id=self.etudiant.dept_id) - + formsemestre_id = formsemestre.id if formsemestre else None + # si pas de formsemestre, utilisera les prefs globales du département + dept_id = self.etudiant.dept_id + force = is_assiduites_module_forced( + formsemestre_id=formsemestre_id, dept_id=dept_id + ) if force: raise ScoValueError("Module non renseigné") - return True - class Justificatif(ScoDocModel): """ diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index c110dacf..e12ff28d 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -419,7 +419,9 @@ def moduleimpl_inscriptions_stats(formsemestre_id): for info in ues_cap_info[ue["ue_id"]]: etud = sco_etud.get_etud_info(etudid=info["etudid"], filled=True)[0] H.append( - f"""
    • bool: + """Vrai si préférence "imposer la saisie du module" sur les assiduités est vraie.""" from app.scodoc import sco_preferences - retour: bool - - if dept_id is None: - dept_id = g.scodoc_dept_id - - try: - retour = sco_preferences.get_preference( - "forcer_module", formsemestre_id=int(formsemestre_id) - ) - except (TypeError, ValueError): - retour = sco_preferences.get_preference("forcer_module", dept_id=dept_id) - return retour + return sco_preferences.get_preference( + "forcer_module", formsemestre_id=formsemestre_id, dept_id=dept_id + ) def get_assiduites_time_config(config_type: str) -> str | int: diff --git a/app/templates/assiduites/pages/ajout_assiduites.j2 b/app/templates/assiduites/pages/ajout_assiduites.j2 deleted file mode 100644 index 85b7697a..00000000 --- a/app/templates/assiduites/pages/ajout_assiduites.j2 +++ /dev/null @@ -1,235 +0,0 @@ -{% include "assiduites/widgets/toast.j2" %} -{% include "assiduites/widgets/alert.j2" %} - -{% block pageContent %} -
      -

      Signaler une absence, présence ou retard pour {{etud.html_link_fiche()|safe}}

      - {% if saisie_eval %} -
      - {% endif %} -
      -
      -
      -
      - Date de début - - - Journée entière -
      -
      - Date de fin - -
      -
      - -
      -
      - État de l'assiduité - -
      -
      -
      -
      - Module - {% with moduleid="ajout_assiduite_module_impl",label=false %} - {% include "assiduites/widgets/moduleimpl_dynamic_selector.j2" %} - {% endwith %} -
      -
      - -
      -
      - Raison - -
      -
      - -
      - - -
      - - -
      - -
      -
      - {{tableau | safe }} -
      - -
      - - -{% include "sco_timepicker.j2" %} - -{% endblock pageContent %} diff --git a/app/templates/assiduites/pages/liste_semestre.j2 b/app/templates/assiduites/pages/liste_semestre.j2 deleted file mode 100644 index 90c996da..00000000 --- a/app/templates/assiduites/pages/liste_semestre.j2 +++ /dev/null @@ -1,94 +0,0 @@ -{% block pageContent %} -
      -

      Assiduites et justificatifs de {{sem}}

      - {% include "assiduites/widgets/tableau_base.j2" %} - -

      Assiduité :

      - - - - - {% include "assiduites/widgets/tableau_assi.j2" %} -

      Justificatifs :

      - - - - - {% include "assiduites/widgets/tableau_justi.j2" %} - -
      - -{% endblock pageContent %} \ No newline at end of file diff --git a/app/templates/assiduites/pages/signal_assiduites_etud.j2 b/app/templates/assiduites/pages/signal_assiduites_etud.j2 deleted file mode 100644 index ea453095..00000000 --- a/app/templates/assiduites/pages/signal_assiduites_etud.j2 +++ /dev/null @@ -1,160 +0,0 @@ -{# -*- mode: jinja-html -*- #} -{% include "assiduites/widgets/toast.j2" %} -{% include "assiduites/widgets/alert.j2" %} -{% include "assiduites/widgets/prompt.j2" %} -{% include "assiduites/widgets/conflict.j2" %} -
      - {% block content %} -

      Signalement de l'assiduité de {{sco.etud.nomprenom}}

      - -
      - Date: - -
      - - {{timeline|safe}} - - -
      - {% include "assiduites/widgets/moduleimpl_dynamic_selector.j2" %} - -
      - -
      - - - -
      - -
      -
      -
      -
      -
      -
      - - {% if saisie_eval %} -
      -
      -

      - La saisie de l'assiduité a été préconfigurée en fonction de l'évaluation.
      - Une fois la saisie finie, cliquez sur le lien si dessous pour revenir sur la gestion de l'évaluation -

      - retourner sur la page de l'évaluation -
      - {% endif %} - - {{diff | safe}} - -
      -

      Explication de la timeline

      -

      - Si la période indiquée par la timeline provoque un conflit d'assiduité pour un étudiant sa ligne deviendra - rouge. -
      - Dans ce cas il faut résoudre manuellement le conflit : cliquez sur un des boutons d'assiduités pour ouvrir - le - résolveur de conflit. -
      - Correspondance des couleurs : -

      -
        - {% include "assiduites/widgets/legende_couleur.j2" %} -
      - -

      Vous pouvez justifier rapidement une assiduité en saisisant l'assiduité puis en appuyant sur "Justifier"

      - -

      Explication de la saisie différée

      -

      Si la colonne n'est pas valide elle sera affichée en rouge, passez le curseur sur la colonne pour afficher - le message d'erreur

      -

      Sélectionner la date de début de la colonne mettra automatiquement la date de fin à la durée d'une séance - (préférence de département)

      -

      Modifier le module alors que des informations sont déjà enregistrées pour la période changera leur - module.

      -

      Il y a 4 boutons sur la colonne permettant d'enregistrer l'information pour tous les étudiants

      -

      Le dernier des boutons retire l'information présente.

      -

      Vous pouvez ajouter des colonnes en appuyant sur le bouton +

      -

      Vous pouvez supprimer une colonne en appuyant sur la croix qui se situe dans le coin haut droit de la - colonne. -

      -
      - - - -
      -
      -
      - - - - - - - - - {% endblock %} - -
      \ No newline at end of file diff --git a/app/templates/assiduites/widgets/moduleimpl_dynamic_selector.j2 b/app/templates/assiduites/widgets/moduleimpl_dynamic_selector.j2 deleted file mode 100644 index 80712eed..00000000 --- a/app/templates/assiduites/widgets/moduleimpl_dynamic_selector.j2 +++ /dev/null @@ -1,156 +0,0 @@ -
      - {% if label != false%} - - {% else %} - {% endif %} - {% if moduleid %} - - {% else %} - - {% endif %} - - -
      - - - - - \ No newline at end of file diff --git a/app/templates/assiduites/widgets/simplemoduleimpl_select.j2 b/app/templates/assiduites/widgets/simplemoduleimpl_select.j2 index f41dce59..03c4c205 100644 --- a/app/templates/assiduites/widgets/simplemoduleimpl_select.j2 +++ b/app/templates/assiduites/widgets/simplemoduleimpl_select.j2 @@ -1,10 +1,7 @@ -{% if scu.is_assiduites_module_forced(request.args.get('formsemestre_id', None))%} - +{% if scu.is_assiduites_module_forced(formsemestre_id)%} + {% else %} - + {% endif %} -{% if moduleimpl_id == "autre" %} - -{% else %} - -{% endif %} \ No newline at end of file + + diff --git a/app/templates/assiduites/widgets/tableau_assi.j2 b/app/templates/assiduites/widgets/tableau_assi.j2 deleted file mode 100644 index c273c6c6..00000000 --- a/app/templates/assiduites/widgets/tableau_assi.j2 +++ /dev/null @@ -1,465 +0,0 @@ - - - - - - - - - - - - -
      -
      - Début - -
      -
      -
      - Fin - -
      -
      -
      - État - -
      -
      -
      - Module - -
      -
      -
      - Justifiée - -
      -
      -
      -
      - - - - diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 07d8a691..b44ea0d4 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -2101,15 +2101,19 @@ def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> s return render_template( "assiduites/widgets/moduleimpl_selector.j2", - selected=selected, + formsemestre_id=formsemestre.id, modules=modules, moduleimpl_id=moduleimpl_id, + selected=selected, ) def _module_selector_multiple( etud: Identite, moduleimpl_id: int = None, only_form: FormSemestre = None ) -> str: + """menu HTML