diff --git a/app/forms/assiduite/ajout_assiduite_etud.py b/app/forms/assiduite/ajout_assiduite_etud.py index 4ece2b5b..8c3423ac 100644 --- a/app/forms/assiduite/ajout_assiduite_etud.py +++ b/app/forms/assiduite/ajout_assiduite_etud.py @@ -126,6 +126,7 @@ class AjoutAssiOrJustForm(FlaskForm): class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm): "Formulaire de saisie d'une assiduité pour un étudiant" + description = TextAreaField( "Description", render_kw={ @@ -152,6 +153,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm): class AjoutJustificatifEtudForm(AjoutAssiOrJustForm): "Formulaire de saisie d'un justificatif pour un étudiant" + raison = TextAreaField( "Raison", render_kw={ @@ -176,6 +178,12 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm): class ChoixDateForm(FlaskForm): + """ + Formulaire de choix de date + (utilisé par la page de choix de date + si la date courante n'est pas dans le semestre) + """ + def __init__(self, *args, **kwargs): "Init form, adding a filed for our error messages" super().__init__(*args, **kwargs) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index e08a9211..8580179b 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 sqlalchemy.exc import DataError from app import db, log, g, set_sco_dept from app.models import ( @@ -89,6 +88,8 @@ class Assiduite(ScoDocModel): lazy="select", ) + # Argument "restrict" obligatoire car on override la fonction "to_dict" de ScoDocModel + # pylint: disable-next=unused-argument 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. @@ -307,6 +308,9 @@ class Assiduite(ScoDocModel): def supprime(self): "Supprime l'assiduité. Log et commit." + + # Obligatoire car import circulaire sinon + # pylint: disable-next=import-outside-toplevel from app.scodoc import sco_assiduites as scass if g.scodoc_dept is None and self.etudiant.dept_id is not None: @@ -356,7 +360,7 @@ class Assiduite(ScoDocModel): date: str = self.entry_date.strftime("%d/%m/%Y à %H:%M") utilisateur: str = "" - if self.user != None: + if self.user is not None: self.user: User utilisateur = f"par {self.user.get_prenomnom()}" @@ -515,6 +519,8 @@ class Justificatif(ScoDocModel): def create_justificatif( cls, etudiant: Identite, + # On a besoin des arguments mais on utilise "locals" pour les récupérer + # pylint: disable=unused-argument date_debut: datetime, date_fin: datetime, etat: EtatJustificatif, @@ -538,8 +544,10 @@ class Justificatif(ScoDocModel): def supprime(self): "Supprime le justificatif. Log et commit." + + # Obligatoire car import circulaire sinon + # pylint: disable-next=import-outside-toplevel from app.scodoc import sco_assiduites as scass - from app.scodoc.sco_archives_justificatifs import JustificatifArchiver # Récupération de l'archive du justificatif archive_name: str = self.fichier diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py index 0b3ff913..a4c56cca 100644 --- a/app/scodoc/sco_archives_justificatifs.py +++ b/app/scodoc/sco_archives_justificatifs.py @@ -20,8 +20,11 @@ class Trace: Role des fichiers traces : - Sauvegarder la date de dépôt 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) + - 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 diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 23252ddb..3729d867 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -21,6 +21,7 @@ from app.models.assiduites import Assiduite, Justificatif, compute_assiduites_ju from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_preferences from app.scodoc import sco_cache +from app.scodoc import sco_compute_moy from app.scodoc import sco_etud import app.scodoc.sco_utils as scu @@ -37,21 +38,34 @@ class CountCalculator: ------------ 1. Initialisation : La classe peut être initialisée avec des horaires personnalisés pour le matin, le midi et le soir, ainsi qu'une durée de pause déjeuner. - Si non spécifiés, les valeurs par défaut seront chargées depuis la configuration `ScoDocSiteConfig`. + Si non spécifiés, les valeurs par défaut seront + chargées depuis la configuration `ScoDocSiteConfig`. Exemple d'initialisation : - calculator = CountCalculator(morning="08:00", noon="13:00", evening="18:00", nb_heures_par_jour=8) + calculator = CountCalculator( + morning="08:00", + noon="13:00", + evening="18:00", + nb_heures_par_jour=8 + ) 2. Ajout d'assiduités : Exemple d'ajout d'assiduité : - calculator.compute_assiduites(etudiant.assiduites) - - calculator.compute_assiduites([, , , ]) + - calculator.compute_assiduites([ + , + , + , + + ]) - 3. Accès aux métriques : Après l'ajout des assiduités, on peut accéder aux métriques telles que : + 3. Accès aux métriques : Après l'ajout des assiduités, + on peut accéder aux métriques telles que : le nombre total de jours, de demi-journées et d'heures calculées. Exemple d'accès aux métriques : metrics = calculator.to_dict() - 4.Réinitialisation du comptage: Si besoin on peut réinitialisé le compteur sans perdre la configuration + 4.Réinitialisation du comptage: Si besoin on peut réinitialiser + le compteur sans perdre la configuration (horaires personnalisés) Exemple de réinitialisation : calculator.reset() @@ -61,8 +75,10 @@ class CountCalculator: - reset() : Réinitialise les compteurs de la classe. - add_half_day(day: date, is_morning: bool) : Ajoute une demi-journée au comptage. - add_day(day: date) : Ajoute un jour complet au comptage. - - compute_long_assiduite(assi: Assiduite) : Traite les assiduités s'étendant sur plus d'un jour. - - compute_assiduites(assiduites: Query | list) : Calcule les métriques pour une collection d'assiduités. + - compute_long_assiduite(assi: Assiduite) : Traite les assiduités + s'étendant sur plus d'un jour. + - compute_assiduites(assiduites: Query | list) : Calcule les métriques pour + une collection d'assiduités. - to_dict() : Retourne les métriques sous forme de dictionnaire. Notes : @@ -85,27 +101,20 @@ class CountCalculator: evening: str = None, nb_heures_par_jour: int = None, ) -> None: - # Transformation d'une heure "HH:MM" en time(h,m) - STR_TIME = lambda x: time(*list(map(int, x.split(":")))) - - self.morning: time = STR_TIME( + self.morning: time = str_to_time( morning if morning else ScoDocSiteConfig.get("assi_morning_time", "08:00") ) # Date pivot pour déterminer les demi-journées - self.noon: time = STR_TIME( + self.noon: time = str_to_time( noon if noon else ScoDocSiteConfig.get("assi_lunch_time", "13:00") ) - self.evening: time = STR_TIME( + self.evening: time = str_to_time( evening if evening else ScoDocSiteConfig.get("assi_afternoon_time", "18:00") ) - self.non_work_days: list[scu.NonWorkDays] = ( - scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id) - ) - - delta_total: timedelta = datetime.combine( - date.min, self.evening - ) - datetime.combine(date.min, self.morning) + self.non_work_days: list[ + scu.NonWorkDays + ] = scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id) # Sera utilisé pour les assiduités longues (> 1 journée) self.nb_heures_par_jour = ( @@ -340,17 +349,27 @@ class CountCalculator: def setup_data(self): """Met en forme les données - pour les journées et les demi-journées : au lieu d'avoir list[str] on a le nombre (len(list[str])) + pour les journées et les demi-journées : + au lieu d'avoir list[str] on a le nombre (len(list[str])) """ - for key in self.data: - self.data[key]["journee"] = len(self.data[key]["journee"]) - self.data[key]["demi"] = len(self.data[key]["demi"]) + for value in self.data.values(): + value["journee"] = len(value["journee"]) + value["demi"] = len(value["demi"]) def to_dict(self, only_total: bool = True) -> dict[str, int | float]: """Retourne les métriques sous la forme d'un dictionnaire""" return self.data["total"] if only_total else self.data +def str_to_time(time_str: str) -> time: + """Convertit une chaîne de caractères représentant une heure en objet time + exemples : + - "08:00" -> time(8, 0) + - "18:00:00" -> time(18, 0, 0) + """ + return time(*list(map(int, time_str.split(":")))) + + def get_assiduites_stats( assiduites: Query, metric: str = "all", filtered: dict[str, object] = None ) -> dict[str, int | float]: @@ -756,7 +775,6 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime): pour cet étudiant et cette date. Invalide cache absence et caches semestre """ - from app.scodoc import sco_compute_moy # Semestres a cette date: etud = sco_etud.get_etud_info(etudid=etudid, filled=True) @@ -818,4 +836,4 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None): pattern=f"tableau-etud-{etudid}*" ) # Invalide les tableaux "bilan dept" - sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern=f"tableau-dept*") + sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern="tableau-dept*") diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 7e03f38f..61c8f1ee 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -130,7 +130,8 @@ def print_progress_bar( decimals - Optional : nombres de chiffres après la virgule (Int) length - Optional : taille de la barre en nombre de caractères (Int) fill - Optional : charactère de remplissange de la barre (Str) - autosize - Optional : Choisir automatiquement la taille de la barre en fonction du terminal (Bool) + autosize - Optional : Choisir automatiquement la taille de la barre + en fonction du terminal (Bool) """ percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) color = TerminalColor.RED @@ -174,11 +175,15 @@ class BiDirectionalEnum(Enum): @classmethod def contains(cls, attr: str): """Vérifie sur un attribut existe dans l'enum""" + + # Existe dans la classe parent de Enum (EnumType) + # pylint: disable-next=no-member return attr.upper() in cls._member_names_ @classmethod def all(cls, keys=True): """Retourne toutes les clés de l'enum""" + # pylint: disable-next=no-member return cls._member_names_ if keys else list(cls._value2member_map_.keys()) @classmethod @@ -207,6 +212,9 @@ class EtatAssiduite(int, BiDirectionalEnum): ABSENT = 2 def version_lisible(self) -> str: + """Retourne une version lisible des états d'assiduités + Est utilisé pour les vues. + """ return { EtatAssiduite.PRESENT: "Présence", EtatAssiduite.ABSENT: "Absence", @@ -225,6 +233,9 @@ class EtatJustificatif(int, BiDirectionalEnum): MODIFIE = 3 def version_lisible(self) -> str: + """Retourne une version lisible des états de justificatifs + Est utilisé pour les vues. + """ return { EtatJustificatif.VALIDE: "valide", EtatJustificatif.ATTENTE: "soumis", @@ -254,11 +265,13 @@ class NonWorkDays(int, BiDirectionalEnum): cls, formsemestre_id: int = None, dept_id: int = None ) -> list["NonWorkDays"]: """ - get_all_non_work_days Récupère la liste des non workdays (str) depuis les préférences + get_all_non_work_days Récupère la liste des non workdays + (str) depuis les préférences puis renvoie une liste BiDirectionnalEnum NonWorkDays Example: - non_work_days : list[NonWorkDays] = NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id) + non_work_days : list[NonWorkDays] = + NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id) if datetime.datetime.now().weekday() in non_work_days: print("Aujourd'hui est un jour non travaillé") @@ -269,6 +282,8 @@ class NonWorkDays(int, BiDirectionalEnum): Returns: list[NonWorkDays]: La liste des NonWorkDays en version BiDirectionnalEnum """ + # Import circulaire + # pylint: disable=import-outside-toplevel from app.scodoc import sco_preferences return [ diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index c720fac7..2441f101 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -1,3 +1,8 @@ +""" +Gestion des listes d'assiduités et justificatifs +(affichage, pagination, filtrage, options d'affichage, tableaux) +""" + from datetime import datetime from flask import url_for @@ -8,10 +13,18 @@ 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.scodoc.sco_utils import ( + EtatAssiduite, + EtatJustificatif, + to_bool, + date_debut_annee_scolaire, + date_fin_annee_scolaire, + localize_datetime, +) from app.tables import table_builder as tb from app.scodoc.sco_cache import RequeteTableauAssiduiteCache from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_preferences import get_preference class Pagination: @@ -26,9 +39,11 @@ class Pagination: 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) + (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 + l'intéret est de ne pas garder en mémoire toute la collection, + mais seulement la page courante """ @@ -37,9 +52,11 @@ class Pagination: __init__ Instancie un nouvel objet Pagination Args: - collection (list): La collection à paginer. Il s'agit par exemple d'une requête + 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) + 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 @@ -195,6 +212,17 @@ class ListeAssiJusti(tb.Table): r = query_finale.all() RequeteTableauAssiduiteCache.set(cle_cache, r) + # Filtrer Si préférence "Limiter les assiduités à l'année courante" + if get_preference("assi_limit_annee"): + annee_debut = localize_datetime(date_debut_annee_scolaire()) + annee_fin = localize_datetime(date_fin_annee_scolaire()) + r = [ + obj + for obj in r + if obj._asdict()["date_debut"] >= annee_debut + and obj._asdict()["date_fin"] <= annee_fin + ] + # Paginer la requête pour ne pas envoyer trop d'informations au client pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination) self.total_pages = pagination.total_pages @@ -212,15 +240,17 @@ class ListeAssiJusti(tb.Table): attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`. Args: - collection (list): La collection à paginer. Il s'agit par exemple 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. + Pagination: Un objet Pagination qui encapsule les résultats de + la requête paginée. Note: - Cette méthode ne modifie pas la collection originelle; elle renvoie plutôt un nouvel - objet qui contient les résultats paginés. + Cette méthode ne modifie pas la collection originelle; + elle renvoie plutôt un nouvel objet qui contient les résultats paginés. """ return Pagination( collection, @@ -232,29 +262,35 @@ class ListeAssiJusti(tb.Table): """ Combine les requêtes d'assiduités et de justificatifs en une seule requête. - Cette fonction prend en entrée deux requêtes optionnelles, une pour les assiduités - et une pour les justificatifs, et renvoie une requête combinée qui sélectionne - un ensemble spécifique de colonnes pour chaque type d'objet. + Cette fonction prend en entrée deux requêtes optionnelles, + une pour les assiduités et une pour les justificatifs, + et renvoie une requête combinée qui sélectionne un ensemble + spécifique de colonnes pour chaque type d'objet. Les colonnes sélectionnées sont: - - obj_id: l'identifiant de l'objet (assiduite_id pour les assiduités, justif_id pour les justificatifs) + - obj_id: l'identifiant de l'objet + (assiduite_id pour les assiduités, justif_id pour les justificatifs) - etudid: l'identifiant de l'étudiant - entry_date: la date de saisie de l'objet - date_debut: la date de début de l'objet - date_fin: la date de fin de l'objet - etat: l'état de l'objet - - type: le type de l'objet ("assiduite" pour les assiduités, "justificatif" pour les justificatifs) + - type: le type de l'objet + ("assiduite" pour les assiduités, "justificatif" pour les justificatifs) - est_just : si l'assiduité est justifié (booléen) None pour les justificatifs - - user_id : l'identifiant de l'utilisateur qui a signalé l'assiduité ou le justificatif + - user_id : l'identifiant de l'utilisateur qui a + signalé l'assiduité ou le justificatif Args: query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les assiduités. - Si None (default), aucune assiduité ne sera incluse dans la requête combinée. + Si None (default), aucune assiduité ne sera incluse + dans la requête combinée. query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les justificatifs. - Si None (default), aucun justificatif ne sera inclus dans la requête combinée. + Si None (default), aucun justificatif ne sera + inclus dans la requête combinée. Returns: sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour @@ -599,10 +635,15 @@ class AssiFiltre: Args: type_obj (int, optional): type d'objet (0:Tout, 1: Assi, 2:Justi). Defaults to 0. - entry_date (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. - date_debut (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. - date_fin (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. - etats (list[int | EtatJustificatif | EtatAssiduite], optional): liste d'états valides (int | EtatJustificatif | EtatAssiduite). Defaults to None. + entry_date (tuple[int, datetime], optional): + (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. + date_debut (tuple[int, datetime], optional): + (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. + date_fin (tuple[int, datetime], optional): + (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. + etats (list[int | EtatJustificatif | EtatAssiduite], optional): + liste d'états valides (int | EtatJustificatif | EtatAssiduite). + Defaults to None. """ self.filtres = {"type_obj": type_obj} @@ -637,7 +678,7 @@ class AssiFiltre: type_filtrage, date = val_filtre - match (type_filtrage): + match type_filtrage: # On garde uniquement les dates supérieures au filtre case 2: query_filtree = query_filtree.filter( @@ -734,6 +775,10 @@ class AssiJustifData: @staticmethod def from_etudiants(*etudiants: Identite) -> "AssiJustifData": + """ + Génère un object AssiJustifData à partir d'une liste d'étudiants + (Récupère les assiduités et justificatifs des étudiants) + """ data = AssiJustifData() data.assiduites_query = Assiduite.query.filter( Assiduite.etudid.in_([e.etudid for e in etudiants]) @@ -745,4 +790,5 @@ class AssiJustifData: return data def get(self) -> tuple[Query, Query]: + "Renvoi les requêtes d'assiduités et justificatifs" return self.assiduites_query, self.justificatifs_query diff --git a/app/tables/visu_assiduites.py b/app/tables/visu_assiduites.py index f4061280..8c99f493 100644 --- a/app/tables/visu_assiduites.py +++ b/app/tables/visu_assiduites.py @@ -37,7 +37,7 @@ class TableAssi(tb.Table): convert_values=False, **kwargs, ): - self.rows: list["RowEtud"] = [] # juste pour que VSCode nous aide sur .rows + self.rows: list["RowAssi"] = [] # juste pour que VSCode nous aide sur .rows classes = ["gt_table"] self.dates = [str(dates[0]) + "T00:00", str(dates[1]) + "T23:59"] self.formsemestre = formsemestre diff --git a/app/templates/assiduites/pages/calendrier_assi_etud.j2 b/app/templates/assiduites/pages/calendrier_assi_etud.j2 index f0478610..8ef817de 100644 --- a/app/templates/assiduites/pages/calendrier_assi_etud.j2 +++ b/app/templates/assiduites/pages/calendrier_assi_etud.j2 @@ -3,9 +3,9 @@ Calendrier de l'assiduité {% endblock title %} {% block styles %} - {{ super() }} - - +{{ super() }} + + {% endblock styles %} {% block app_content %} @@ -15,379 +15,388 @@ Calendrier de l'assiduité

Assiduité de {{sco.etud.html_link_fiche()|safe}}

- - - + + +
-
- {% for mois,jours in calendrier.items() %} -
-

{{mois}}

-
- {% for jour in jours %} - {% if jour.is_non_work() %} -
- {{jour.get_nom()}} - {% else %} -
+
+ {% for mois,jours in calendrier.items() %} +
+

{{mois}}

+
+ {% for jour in jours %} + {% if jour.is_non_work() %} +
+ {{jour.get_nom()}} + {% else %} +
{% endif %} {% if mode_demi %} - {% if not jour.is_non_work() %} - {{jour.get_nom()}} - - - {% endif %} + {% if not jour.is_non_work() %} + {{jour.get_nom()}} + + + {% endif %} {% else %} - {% if not jour.is_non_work() %} - {{jour.get_nom(False)}} - {% endif %} + {% if not jour.is_non_work() %} + {{jour.get_nom(False)}} + {% endif %} {% endif %} {% if not jour.is_non_work() and jour.has_assiduites()%} - +
- Assiduité du -
- {{jour.get_date()}} - {{jour.generate_minitimeline() | safe}} + Assiduité du +
+ {{jour.get_date()}} + {{jour.generate_minitimeline() | safe}}
{% endif %} -
+
{% endfor %}
- {% endfor %} -
-
- Année scolaire 2022-2023Changer - année: - + {% endfor %} +
+
+ Année scolaireChanger + année: + - Assiduité de {{sco.etud.nomprenom}} -
+ Assiduité de {{sco.etud.nomprenom}} +
-
-

Calendrier

-

Code couleur

-
    -
  • → présence de l'étudiant lors de la période -
  • -
  • → la période n'est pas travaillée -
  • -
  • → absence de l'étudiant lors de la période -
  • -
  • → absence justifiée -
  • -
  • → retard de l'étudiant lors de la période -
  • -
  • → retard justifié -
  • +
    +

    Calendrier

    +

    Code couleur

    +
      +
    • → présence de l'étudiant lors de la + période +
    • +
    • → la période n'est pas travaillée +
    • +
    • → absence de l'étudiant lors de la + période +
    • +
    • → absence justifiée +
    • +
    • → retard de l'étudiant lors de la + période +
    • +
    • → retard justifié +
    • -
    • → la période est couverte par un +
    • → la période est couverte par un + justificatif valide
    • +
    • → la période est + couverte par un justificatif non valide +
    • +
    • → la période + a un justificatif en attente de validation +
    • +
    + + +

    Vous pouvez passer le curseur sur les jours colorés afin de voir les informations supplémentaires

    +
    +
      +
    • présence +
    • +
    • non travaillé +
    • +
    • absence +
    • +
    • absence justifiée +
    • +
    • retard +
    • +
    • retard justifié +
    • +
    • justificatif valide
    • -
    • → la période est - couverte par un justificatif non valide -
    • -
    • → la période - a un justificatif en attente de validation +
    • justificatif non valide
    - - -

    Vous pouvez passer le curseur sur les jours colorés afin de voir les informations supplémentaires

-
    -
  • présence -
  • -
  • non travaillé -
  • -
  • absence -
  • -
  • absence justifiée -
  • -
  • retard -
  • -
  • retard justifié -
  • -
  • - justificatif valide
  • -
  • justificatif non valide -
  • -
-
- - -{% endblock app_content %} + document.querySelectorAll('input[type="checkbox"].memo, #annee').forEach(el => { + el.addEventListener('change', function () { + updatePage(); + }) + }); + + document.querySelectorAll('[assi_id]').forEach((el, i) => { + el.addEventListener('click', () => { + const assi_id = el.getAttribute('assi_id'); + window.open(`${SCO_URL}/Assiduites/tableau_assiduite_actions?type=assiduite&action=details&obj_id=${assi_id}`); + }) + }); + + + + {% endblock app_content %} \ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 0333aa0b..f95aeff5 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -25,8 +25,10 @@ ############################################################################## import datetime +import json import re -from typing import Any + +from collections import OrderedDict from flask import g, request, render_template, flash from flask import abort, url_for, redirect, Response @@ -121,7 +123,6 @@ def bilan_dept(): if formsemestre_id: try: formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) - annee = formsemestre.annee_scolaire() except AttributeError: formsemestre_id = "" @@ -230,17 +231,22 @@ def ajout_assiduite_etud() -> str | Response: # On dresse la liste des modules de l'année scolaire en cours # auxquels est inscrit l'étudiant pour peupler le menu "module" modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) - choices = { - "": [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")] - } + choices: OrderedDict = OrderedDict() + choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")] for formsemestre_id in modimpls_by_formsemestre: formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) + # indique le nom du semestre dans le menu (optgroup) - choices[formsemestre.titre_annee()] = [ + group_name: str = formsemestre.titre_annee() + choices[group_name] = [ (m.id, 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 ] + + if formsemestre.est_courant(): + choices.move_to_end(group_name, last=False) + choices.move_to_end("", last=False) form.modimpl.choices = choices if form.validate_on_submit(): @@ -532,7 +538,7 @@ def bilan_etud(): # 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 + Assiduite.etat != scu.EtatAssiduite.PRESENT, Assiduite.est_just is False ), etud.justificatifs.filter( Justificatif.etat.in_( @@ -825,17 +831,19 @@ def calendrier_assi_etud(): # Récupération des années d'étude de l'étudiant annees: list[int] = [] for ins in etud.formsemestre_inscriptions: + date_deb = ins.formsemestre.date_debut + date_fin = ins.formsemestre.date_fin annees.extend( - (ins.formsemestre.date_debut.year, ins.formsemestre.date_fin.year) + [ + scu.annee_scolaire_repr(date_deb.year, date_deb.month), + scu.annee_scolaire_repr(date_fin.year, date_fin.month), + ] ) annees = sorted(annees, reverse=True) # Transformation en une liste "json" # (sera utilisé pour générer le selecteur d'année) - annees_str: str = "[" - for ann in annees: - annees_str += f"{ann}," - annees_str += "]" + annees_str: str = json.dumps(annees) calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee) @@ -857,6 +865,15 @@ def calendrier_assi_etud(): @scodoc @permission_required(Permission.AbsChange) def choix_date() -> str: + """ + choix_date Choix de la date pour la saisie des assiduités + + Route utilisée uniquement si la date courante n'est pas dans le semestre + concerné par la requête vers une des pages suivantes : + - saisie_assiduites_group + - visu_assiduites_group + + """ formsemestre_id = request.args.get("formsemestre_id") formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) @@ -973,9 +990,6 @@ def signal_assiduites_group(): if formsemestre.dept_id != g.scodoc_dept_id: abort(404, "groupes inexistants dans ce département") - # Vérification du forçage du module - require_module = sco_preferences.get_preference("forcer_module", formsemestre_id) - # Récupération des étudiants des groupes etuds = [ sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] @@ -1107,9 +1121,6 @@ def visu_assiduites_group(): if formsemestre.dept_id != g.scodoc_dept_id: abort(404, "groupes inexistants dans ce département") - # Vérfication du forçage du module - require_module = sco_preferences.get_preference("forcer_module", formsemestre_id) - # Récupération des étudiants du/des groupe(s) etuds = [ sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] @@ -1781,22 +1792,6 @@ def signal_assiduites_diff(): ) etudiants = list(sorted(etudiants, key=lambda etud: etud.sort_key)) - # Génération de l'HTML - - header: str = html_sco_header.sco_header( - page_title="Assiduité: saisie différée", - init_qtip=True, - cssstyles=[ - "css/assiduites.css", - ], - javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS - + [ - "js/assiduites.js", - "js/date_utils.js", - "js/etud_info.js", - ], - ) - if groups_infos.tous_les_etuds_du_sem: gr_tit = "en" else: @@ -2075,8 +2070,8 @@ def _differee( etudiants (list[dict]): la liste des étudiants (représentés par des dictionnaires) moduleimpl_select (str): l'html représentant le selecteur de module date (str, optional): la première date à afficher. Defaults to None. - periode (dict[str, str], optional):La période par défaut de la première colonne. Defaults to None. - formsemestre_id (int, optional): l'id du semestre pour le selecteur de module. Defaults to None. + periode (dict[str, str], optional):La période par défaut de la première colonne. + formsemestre_id (int, optional): l'id du semestre pour le selecteur de module. Returns: str: le widget (html/css/js) @@ -2162,7 +2157,7 @@ def _module_selector_multiple( Prend les semestres de l'année, sauf si only_form est indiqué. """ modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) - choices = {} + choices = OrderedDict() for formsemestre_id in modimpls_by_formsemestre: formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) if only_form is not None and formsemestre != only_form: @@ -2177,6 +2172,9 @@ def _module_selector_multiple( if m.module.ue.type == UE_STANDARD ] + if formsemestre.est_courant(): + choices.move_to_end(formsemestre.titre_annee(), last=False) + return render_template( "assiduites/widgets/moduleimpl_selector_multiple.j2", choices=choices, @@ -2262,6 +2260,9 @@ def generate_calendar( etudiant: Identite, annee: int = None, ) -> dict[str, list["Jour"]]: + """ + Génère le calendrier d'assiduité de l'étudiant pour une année scolaire donnée + """ # Si pas d'année alors on prend l'année scolaire en cours if annee is None: annee = scu.annee_scolaire() @@ -2309,13 +2310,26 @@ class Jour: self.justificatifs = justificatifs def get_nom(self, mode_demi: bool = True) -> str: + """ + Renvoie le nom du jour + "M19" ou "Mer 19" + """ 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}" + return ( + f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour[:3]+' '}" + + f"{self.date.day}" + ) def get_date(self) -> str: + """ + Renvoie la date du jour au format "dd/mm/yyyy" + """ return self.date.strftime("%d/%m/%Y") def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str: + """ + Retourne la classe css du jour (mode normal) + """ etat = "" est_just = "" @@ -2337,13 +2351,16 @@ class Jour: def get_demi_class( self, matin: bool, show_pres: bool = False, show_reta: bool = False ) -> str: - # Transformation d'une heure "HH:MM" en time(h,m) - str2time = lambda x: datetime.time(*list(map(int, x.split(":")))) + """ + Renvoie la class css de la demi journée + """ - heure_midi = str2time(ScoDocSiteConfig.get("assi_lunch_time", "13:00")) + heure_midi = scass.str_to_time(ScoDocSiteConfig.get("assi_lunch_time", "13:00")) if matin: - heure_matin = str2time(ScoDocSiteConfig.get("assi_morning_time", "08:00")) + heure_matin = scass.str_to_time( + ScoDocSiteConfig.get("assi_morning_time", "08:00") + ) matin = ( # date debut scu.localize_datetime( @@ -2355,12 +2372,16 @@ class Jour: assiduites_matin = [ assi for assi in self.assiduites - if scu.is_period_overlapping((assi.date_debut, assi.date_fin), matin) + if scu.is_period_overlapping( + (assi.date_debut, assi.date_fin), matin, bornes=False + ) ] justificatifs_matin = [ justi for justi in self.justificatifs - if scu.is_period_overlapping((justi.date_debut, justi.date_fin), matin) + if scu.is_period_overlapping( + (justi.date_debut, justi.date_fin), matin, bornes=False + ) ] etat = self._get_color_assiduites_cascade( @@ -2375,7 +2396,9 @@ class Jour: return f"color {etat} {est_just}" - heure_soir = str2time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00")) + heure_soir = scass.str_to_time( + ScoDocSiteConfig.get("assi_afternoon_time", "17:00") + ) # séparation en demi journées aprem = ( @@ -2388,13 +2411,17 @@ class Jour: assiduites_aprem = [ assi for assi in self.assiduites - if scu.is_period_overlapping((assi.date_debut, assi.date_fin), aprem) + if scu.is_period_overlapping( + (assi.date_debut, assi.date_fin), aprem, bornes=False + ) ] justificatifs_aprem = [ justi for justi in self.justificatifs - if scu.is_period_overlapping((justi.date_debut, justi.date_fin), aprem) + if scu.is_period_overlapping( + (justi.date_debut, justi.date_fin), aprem, bornes=False + ) ] etat = self._get_color_assiduites_cascade( @@ -2410,21 +2437,24 @@ class Jour: return f"color {etat} {est_just}" def has_assiduites(self) -> bool: + """ + Renverra True si le jour a des assiduités + """ return self.assiduites.count() > 0 def generate_minitimeline(self) -> str: + """ + Génère la minitimeline du jour + """ # Récupérer le référenciel de la timeline - str2time = lambda x: _time_to_timedelta( + scass.str_to_time = lambda x: _time_to_timedelta( datetime.time(*list(map(int, x.split(":")))) ) - heure_matin: datetime.timedelta = str2time( + heure_matin: datetime.timedelta = scass.str_to_time( ScoDocSiteConfig.get("assi_morning_time", "08:00") ) - heure_midi: datetime.timedelta = str2time( - ScoDocSiteConfig.get("assi_lun_time", "13:00") - ) - heure_soir: datetime.timedelta = str2time( + heure_soir: datetime.timedelta = scass.str_to_time( ScoDocSiteConfig.get("assi_afternoon_time", "17:00") ) # longueur_timeline = heure_soir - heure_matin @@ -2433,6 +2463,7 @@ class Jour: # chaque block d'assiduité est défini par: # longueur = ( (fin-deb) / longueur_timeline ) * 100 # emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100 + # longueur + emplacement = 100% sinon on réduit longueur assiduite_blocks: list[dict[str, float | str]] = [] @@ -2448,8 +2479,10 @@ class Jour: else heure_soir ) - longueur: float = ((fin - deb) / longueur_timeline) * 100 emplacement: float = ((deb - heure_matin) / longueur_timeline) * 100 + longueur: float = ((fin - deb) / longueur_timeline) * 100 + if longueur + emplacement > 100: + longueur = 100 - emplacement etat: str = scu.EtatAssiduite(assi.etat).name.lower() est_just: str = "est_just" if assi.est_just else "" @@ -2470,17 +2503,21 @@ class Jour: ) def is_non_work(self): + """ + Renvoie True si le jour est un jour non travaillé + (en fonction de la préférence du département) + """ return self.date.weekday() in scu.NonWorkDays.get_all_non_work_days( dept_id=g.scodoc_dept_id ) def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]: - return list(set([scu.EtatAssiduite(assi.etat) for assi in assiduites])) + return list(set(scu.EtatAssiduite(assi.etat) for assi in assiduites)) def _get_etats_from_justificatifs( self, justificatifs: Query ) -> list[scu.EtatJustificatif]: - return list(set([scu.EtatJustificatif(justi.etat) for justi in justificatifs])) + return list(set(scu.EtatJustificatif(justi.etat) for justi in justificatifs)) def _get_color_assiduites_cascade( self,