diff --git a/app/api/departements.py b/app/api/departements.py index b82966e3..7d056e46 100644 --- a/app/api/departements.py +++ b/app/api/departements.py @@ -187,7 +187,7 @@ def dept_etudiants(acronym: str): ] """ dept = Departement.query.filter_by(acronym=acronym).first_or_404() - return [etud.to_dict_short() for etud in dept.etats_civils] + return [etud.to_dict_short() for etud in dept.etudiants] @bp.route("/departement/id//etudiants") @@ -200,7 +200,7 @@ def dept_etudiants_by_id(dept_id: int): Retourne la liste des étudiants d'un département d'id donné. """ dept = Departement.query.get_or_404(dept_id) - return [etud.to_dict_short() for etud in dept.etats_civils] + return [etud.to_dict_short() for etud in dept.etudiants] @bp.route("/departement//formsemestres_ids") diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index e2c45c9f..d55d1e88 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -114,10 +114,13 @@ class EtudCursusBUT: validation_rcue: ApcValidationRCUE for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): niveau = validation_rcue.niveau() - if ( - niveau is None - or not niveau.competence.id in self.validation_par_competence_et_annee - ): + if niveau is None: + raise ScoValueError( + """UE d'un RCUE non associée à un niveau de compétence. + Vérifiez la formation et les associations de ses UEs. + """ + ) + if not niveau.competence.id in self.validation_par_competence_et_annee: self.validation_par_competence_et_annee[niveau.competence.id] = {} previous_validation = self.validation_par_competence_et_annee.get( niveau.competence.id 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..3b991740 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -37,21 +37,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 +74,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,17 +100,14 @@ 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") ) @@ -103,10 +115,6 @@ class CountCalculator: 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) - # Sera utilisé pour les assiduités longues (> 1 journée) self.nb_heures_par_jour = ( nb_heures_par_jour @@ -340,17 +348,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 +774,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) @@ -776,17 +793,9 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime): # Invalide les PDF et les absences: for sem in sems: # Inval cache bulletin et/ou note_table - if sco_compute_moy.formsemestre_expressions_use_abscounts( - sem["formsemestre_id"] - ): - # certaines formules utilisent les absences - pdfonly = False - else: - # efface toujours le PDF car il affiche en général les absences - pdfonly = True - + # efface toujours le PDF car il affiche en général les absences sco_cache.invalidate_formsemestre( - formsemestre_id=sem["formsemestre_id"], pdfonly=pdfonly + formsemestre_id=sem["formsemestre_id"], pdfonly=True ) # Inval cache compteurs absences: @@ -818,4 +827,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_evaluation_check_abs.py b/app/scodoc/sco_evaluation_check_abs.py index eb35312d..0349e8f5 100644 --- a/app/scodoc/sco_evaluation_check_abs.py +++ b/app/scodoc/sco_evaluation_check_abs.py @@ -70,9 +70,9 @@ def evaluation_check_absences(evaluation: Evaluation): deb <= Assiduite.date_fin, ) - abs_etudids = set(assi.etudid for assi in assiduites) - abs_nj_etudids = set(assi.etudid for assi in assiduites if assi.est_just is False) - just_etudids = set(assi.etudid for assi in assiduites if assi.est_just is True) + abs_etudids = {assi.etudid for assi in assiduites} + abs_nj_etudids = {assi.etudid for assi in assiduites if assi.est_just is False} + just_etudids = {assi.etudid for assi in assiduites if assi.est_just is True} # Les notes: notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id) diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 921c0070..9321b511 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -1337,21 +1337,12 @@ def do_formsemestre_clone( % (pname, pvalue, formsemestre_id) ) - # 5- Copy formules utilisateur - objs = sco_compute_moy.formsemestre_ue_computation_expr_list( - cnx, args={"formsemestre_id": orig_formsemestre_id} - ) - for obj in objs: - args = obj.copy() - args["formsemestre_id"] = formsemestre_id - _ = sco_compute_moy.formsemestre_ue_computation_expr_create(cnx, args) - - # 6- Copie les parcours + # 5- Copie les parcours formsemestre.parcours = formsemestre_orig.parcours db.session.add(formsemestre) db.session.commit() - # 7- Copy partitions and groups + # 6- Copy partitions and groups if clone_partitions: sco_groups_copy.clone_partitions_and_groups( orig_formsemestre_id, formsemestre_id diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index c5fce57b..027ac8df 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1216,7 +1216,7 @@ def formsemestre_tableau_modules( if expr: H.append( f""" {expr} - formule inutilisée en 9.2: formule inutilisée en ScoDoc 9: supprimer""" diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 336d49ff..c45d1b84 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -358,11 +358,11 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None): if formsemestre_has_decisions(formsemestre_id): H.append( - """
    -
  • Décisions de jury saisies: seul le ou la responsable du + """
    +
    Décisions de jury saisies: seul le ou la responsable du semestre peut saisir des notes (elle devra modifier les décisions de jury). -
  • -
""" + + """ ) # H.append( 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/static/css/scodoc.css b/app/static/css/scodoc.css index 84e09201..8dfd3c19 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -3407,6 +3407,17 @@ li.tf-msg { padding-bottom: 5px; } +div.formsemestre-warning-box { + background-color: yellow; + border-radius: 4px; + margin-top: 12px; + margin-left: 0px; + padding-left: 0px; + padding-right: 4px; + padding-top: 2px; + /* padding-bottom: 1px; */ +} + .warning, .warning-bloquant { color: red; margin-left: 16px; 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..a3f5379d 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(): @@ -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,19 @@ 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") + ) + log( + f'{ScoDocSiteConfig.get("assi_morning_time", "08:00")=}{heure_matin=} {type(heure_matin)=}' + ) matin = ( # date debut scu.localize_datetime( @@ -2355,12 +2375,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 +2399,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 +2414,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,22 +2440,21 @@ 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( - datetime.time(*list(map(int, x.split(":")))) + heure_matin: datetime.timedelta = _time_to_timedelta( + scass.str_to_time(ScoDocSiteConfig.get("assi_morning_time", "08:00")) ) - - heure_matin: datetime.timedelta = str2time( - ScoDocSiteConfig.get("assi_morning_time", "08:00") - ) - heure_midi: datetime.timedelta = str2time( - ScoDocSiteConfig.get("assi_lun_time", "13:00") - ) - heure_soir: datetime.timedelta = str2time( - ScoDocSiteConfig.get("assi_afternoon_time", "17:00") + heure_soir: datetime.timedelta = _time_to_timedelta( + scass.str_to_time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00")) ) # longueur_timeline = heure_soir - heure_matin longueur_timeline: datetime.timedelta = heure_soir - heure_matin @@ -2433,6 +2462,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 +2478,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 +2502,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, diff --git a/config.py b/config.py index eefebdfc..d98e9513 100755 --- a/config.py +++ b/config.py @@ -61,11 +61,11 @@ class DevConfig(Config): DEBUG = True TESTING = False SQLALCHEMY_DATABASE_URI = ( - os.environ.get("SCODOC_DATABASE_URI") or "postgresql:///SCODOC" + os.environ.get("SCODOC_DATABASE_URI") or "postgresql:///SCODOC_DEV" ) SECRET_KEY = os.environ.get("DEV_SECRET_KEY") or "bb3faec7d9a34eb68a8e3e710087d87a" # pour le avoir url_for dans le shell: - SERVER_NAME = "http://localhost:8080" + # SERVER_NAME = os.environ.get("SCODOC_TEST_SERVER_NAME") or "localhost" class TestConfig(DevConfig): diff --git a/sco_version.py b/sco_version.py index 8c642266..df8dd361 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.946" +SCOVERSION = "9.6.948" SCONAME = "ScoDoc" diff --git a/tools/test_api.sh b/tools/test_api.sh index c91a6ec1..4a915d33 100755 --- a/tools/test_api.sh +++ b/tools/test_api.sh @@ -4,8 +4,8 @@ # Ce script lance un serveur scodoc sur le port 5555 # attend qu'il soit initialisé puis lance les tests client API. # -# On peut aussi le lancer avec l'option --dont-start-server -# auquel cas il utilise un serveur existant, qui doit avoir été lancé +# On peut aussi le lancer avec l'option --dont-start-server +# auquel cas il utilise un serveur existant, qui doit avoir été lancé # par ailleurs, par exemple via le script: # tests/api/start_api_server.sh -p 5555 # @@ -21,7 +21,7 @@ # # E. Viennet, Fev 2023 -cd /opt/scodoc +cd /opt/scodoc || exit 1 # suppose que le virtual env est bien configuré # Utilise un port spécifique pour pouvoir lancer ce test sans couper @@ -57,7 +57,7 @@ then echo "ScoDoc test server logs are in $SERVER_LOG" # Wait for server setup echo -n "Waiting for server" - while ! nc -z localhost "$PORT"; do + while ! nc -z localhost "$PORT"; do echo -n . sleep 1 done