diff --git a/app/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py index 16b6f1279..56fd0534f 100644 --- a/app/forms/main/config_assiduites.py +++ b/app/forms/main/config_assiduites.py @@ -119,15 +119,15 @@ def check_ics_regexp(form, field): class ConfigAssiduitesForm(FlaskForm): "Formulaire paramétrage Module Assiduité" - morning_time = TimeField( + assi_morning_time = TimeField( "Début de la journée" ) # TODO utiliser TextField + timepicker voir AjoutAssiOrJustForm - lunch_time = TimeField( + assi_lunch_time = TimeField( "Heure de midi (date pivot entre matin et après-midi)" ) # TODO - afternoon_time = TimeField("Fin de la journée") # TODO + assi_afternoon_time = TimeField("Fin de la journée") # TODO - tick_time = DecimalField( + assi_tick_time = DecimalField( "Granularité de la timeline (temps en minutes)", places=0, validators=[check_tick_time], diff --git a/app/models/assiduites.py b/app/models/assiduites.py index e58e6aff2..5dc18a489 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -25,6 +25,7 @@ from app.scodoc.sco_utils import ( EtatJustificatif, localize_datetime, is_assiduites_module_forced, + NonWorkDays, ) @@ -154,6 +155,33 @@ class Assiduite(ScoDocModel): ) if date_fin.tzinfo is None: log(f"Warning: create_assiduite: date_fin without timezone ({date_fin})") + + # Vérification jours non travaillés + # -> vérifie si la date de début ou la date de fin est sur un jour non travaillé + # On récupère les formsemestres des dates de début et de fin + formsemetre_date_debut: FormSemestre = get_formsemestre_from_data( + { + "etudid": etud.id, + "date_debut": date_debut, + "date_fin": date_debut, + } + ) + formsemetre_date_fin: FormSemestre = get_formsemestre_from_data( + { + "etudid": etud.id, + "date_debut": date_fin, + "date_fin": date_fin, + } + ) + if date_debut.weekday() in NonWorkDays.get_all_non_work_days( + formsemestre_id=formsemetre_date_debut + ): + raise ScoValueError("La date de début n'est pas un jour travaillé") + if date_fin.weekday() in NonWorkDays.get_all_non_work_days( + formsemestre_id=formsemetre_date_fin + ): + raise ScoValueError("La date de fin n'est pas un jour travaillé") + # Vérification de non duplication des périodes assiduites: Query = etud.assiduites if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite): diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 00268dfed..f46205adb 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -15,59 +15,222 @@ from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_preferences from app.scodoc import sco_cache from app.scodoc import sco_etud +from app.models import ScoDocSiteConfig +from flask import g class CountCalculator: - """Classe qui gére le comptage des assiduités""" + """ + La classe CountCalculator est conçue pour gérer le comptage des assiduités, + en calculant le nombre total de jours complets, + de demi-journées, et d'heures passées sur une période donnée. + Elle prend en compte les jours non travaillés, + les horaires de travail standard et les assiduités s'étendant sur plusieurs jours. - # TODO documenter + Utilisation : + ------------ + 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`. + Exemple d'initialisation : + 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([, , , ]) + + 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 + (horaires personnalisés) + Exemple de réinitialisation : + calculator.reset() + + Méthodes Principales : + --------------------- + - 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. + - to_dict() : Retourne les métriques sous forme de dictionnaire. + + Notes : + ------ + + Détails des calculs des heures: + Pour les assiduités courtes (<= 1 jour): + heures = assi.deb - assi.fin + Pour les assiduités longues (> 1 jour): + heures = + heures(assi.deb => fin_journee) + nb_heure_par_jour * (nb_jours-2) + + heures(assi.fin => fin_journee) + """ def __init__( self, - morning: time = time(8, 0), # TODO utiliser ScoDocSiteConfig - noon: time = time(12, 0), - after_noon: time = time(14, 00), - evening: time = time(18, 0), - skip_saturday: bool = True, # TODO préférence workdays + morning: str = None, + noon: str = None, + evening: str = None, + nb_heures_par_jour: int = None, ) -> None: - self.morning: time = morning - self.noon: time = noon - self.after_noon: time = after_noon - self.evening: time = evening - self.skip_saturday: bool = skip_saturday + # Transformation d'une heure "HH:MM" en time(h,m) + STR_TIME = lambda x: time(*list(map(int, x.split(":")))) - delta_total: timedelta = datetime.combine(date.min, evening) - datetime.combine( - date.min, morning + self.morning: time = STR_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( + noon if noon else ScoDocSiteConfig.get("assi_lunch_time", "13:00") + ) + self.evening: time = STR_TIME( + evening if evening else ScoDocSiteConfig.get("assi_afternoon_time", "18:00") ) - delta_lunch: timedelta = datetime.combine( - date.min, after_noon - ) - datetime.combine(date.min, noon) - self.hour_per_day: float = (delta_total - delta_lunch).total_seconds() / 3600 + self.non_work_days: list[ + scu.NonWorkDays + ] = scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id) - self.days: list[date] = [] - self.half_days: list[tuple[date, bool]] = [] # tuple -> (date, morning:bool) - self.hours: float = 0.0 + delta_total: timedelta = datetime.combine( + date.min, self.evening + ) - datetime.combine(date.min, self.morning) - self.count: int = 0 + # Sera utilisé pour les assiduités longues (> 1 journée) + self.nb_heures_par_jour = ( + nb_heures_par_jour + if nb_heures_par_jour + else sco_preferences.get_preference( + "nb_heures_par_jour", dept_id=g.scodoc_dept_id + ) + ) + + self.data = {} + self.reset() def reset(self): """Remet à zero les compteurs""" - self.days = [] - self.half_days = [] - self.hours = 0.0 - self.count = 0 + self.data = { + "total": { + "journee": [], + "demi": [], + "heure": 0, + "compte": 0, + }, + "absent": { + "journee": [], + "demi": [], + "heure": 0, + "compte": 0, + }, + "absent_just": { + "journee": [], + "demi": [], + "heure": 0, + "compte": 0, + }, + "absent_non_just": { + "journee": [], + "demi": [], + "heure": 0, + "compte": 0, + }, + "retard": { + "journee": [], + "demi": [], + "heure": 0, + "compte": 0, + }, + "retard_just": { + "journee": [], + "demi": [], + "heure": 0, + "compte": 0, + }, + "retard_non_just": { + "journee": [], + "demi": [], + "heure": 0, + "compte": 0, + }, + "present": { + "journee": [], + "demi": [], + "heure": 0, + "compte": 0, + }, + } - def add_half_day(self, day: date, is_morning: bool = True): + def get_count_key(self, etat: scu.EtatAssiduite, justi: bool = False) -> str: + """Récupère une clé de dictionnaire en fonction de l'état de l'assiduité + et si elle est justifié + """ + keys: dict[EtatAssiduite, str] = { + scu.EtatAssiduite.ABSENT: "absent", + scu.EtatAssiduite.RETARD: "retard", + scu.EtatAssiduite.PRESENT: "present", + } + count_key: str = keys.get(etat) + if etat != scu.EtatAssiduite.PRESENT: + count_key += "_just" if justi else "_non_just" + return count_key + + def add_half_day(self, day: date, assi: Assiduite, is_morning: bool = True): """Ajoute une demi-journée dans le comptage""" key: tuple[date, bool] = (day, is_morning) - if key not in self.half_days: - self.half_days.append(key) - def add_day(self, day: date): + count_key: str = self.get_count_key(assi.etat, assi.est_just) + if assi.etat != scu.EtatAssiduite.PRESENT: + _key: str = scu.EtatAssiduite.inverse().get(assi.etat).name.lower() + if key not in self.data[_key]["demi"]: + self.data[_key]["demi"].append(day) + + if key not in self.data["total"]["demi"]: + self.data["total"]["demi"].append(key) + if key not in self.data[count_key]["demi"]: + self.data[count_key]["demi"].append(key) + + def add_day(self, day: date, assi: Assiduite): """Ajoute un jour dans le comptage""" - if day not in self.days: - self.days.append(day) + count_key: str = self.get_count_key(assi.etat, assi.est_just) + if assi.etat != scu.EtatAssiduite.PRESENT: + key: str = scu.EtatAssiduite.inverse().get(assi.etat).name.lower() + if day not in self.data[key]["journee"]: + self.data[key]["journee"].append(day) + + if day not in self.data["total"]["journee"]: + self.data["total"]["journee"].append(day) + if day not in self.data[count_key]["journee"]: + self.data[count_key]["journee"].append(day) + + def add_hours(self, hours: float, assi: Assiduite): + """Ajoute des heures dans le comptage""" + + count_key: str = self.get_count_key(assi.etat, assi.est_just) + if assi.etat != scu.EtatAssiduite.PRESENT: + self.data[scu.EtatAssiduite.inverse().get(assi.etat).name.lower()][ + "heure" + ] += hours + + self.data[count_key]["heure"] += hours + self.data["total"]["heure"] += hours + + def add_count(self, assi: Assiduite): + """Ajoute 1 count dans le comptage""" + + count_key: str = self.get_count_key(assi.etat, assi.est_just) + if assi.etat != scu.EtatAssiduite.PRESENT: + self.data[scu.EtatAssiduite.inverse().get(assi.etat).name.lower()][ + "compte" + ] += 1 + + self.data[count_key]["compte"] += 1 + self.data["total"]["compte"] += 1 def is_in_morning(self, period: tuple[datetime, datetime]) -> bool: """Vérifiée si la période donnée fait partie du matin @@ -90,7 +253,9 @@ class CountCalculator: """ interval_evening: tuple[datetime, datetime] = ( - scu.localize_datetime(datetime.combine(period[0].date(), self.after_noon)), + scu.localize_datetime( + datetime.combine(period[0].date(), self.noon) + timedelta(seconds=1) + ), scu.localize_datetime(datetime.combine(period[0].date(), self.evening)), ) @@ -102,15 +267,9 @@ class CountCalculator: """Calcule les métriques sur une assiduité longue (plus d'un jour)""" pointer_date: date = assi.date_debut.date() + timedelta(days=1) - start_hours: timedelta = assi.date_debut - scu.localize_datetime( - datetime.combine(assi.date_debut, self.morning) - ) - finish_hours: timedelta = assi.date_fin - scu.localize_datetime( - datetime.combine(assi.date_fin, self.morning) - ) - self.add_day(assi.date_debut.date()) - self.add_day(assi.date_fin.date()) + self.add_day(assi.date_debut.date(), assi) + self.add_day(assi.date_fin.date(), assi) start_period: tuple[datetime, datetime] = ( assi.date_debut, @@ -123,58 +282,67 @@ class CountCalculator: scu.localize_datetime(datetime.combine(assi.date_fin.date(), self.morning)), assi.date_fin, ) - hours = 0.0 for period in (start_period, finish_period): if self.is_in_evening(period): - self.add_half_day(period[0].date(), False) + self.add_half_day(period[0].date(), assi, False) if self.is_in_morning(period): - self.add_half_day(period[0].date()) + self.add_half_day(period[0].date(), assi) while pointer_date < assi.date_fin.date(): - # TODO : Utiliser la préférence de département : workdays - if pointer_date.weekday() < (6 - self.skip_saturday): - self.add_day(pointer_date) - self.add_half_day(pointer_date) - self.add_half_day(pointer_date, False) - self.hours += self.hour_per_day - hours += self.hour_per_day + if pointer_date.weekday() not in self.non_work_days: + self.add_day(pointer_date, assi) + self.add_half_day(pointer_date, assi) + self.add_half_day(pointer_date, assi, False) + self.add_hours(self.nb_heures_par_jour, assi) pointer_date += timedelta(days=1) - self.hours += finish_hours.total_seconds() / 3600 - self.hours += self.hour_per_day - (start_hours.total_seconds() / 3600) + # Gestion des heures des dates de début et des dates de fin + deb_hours = (start_period[1] - start_period[0]).total_seconds() / 3600 + fin_hours = (finish_period[1] - finish_period[0]).total_seconds() / 3600 + + self.add_hours(deb_hours + fin_hours, assi) def compute_assiduites(self, assiduites: Query | list): """Calcule les métriques pour la collection d'assiduité donnée""" assi: Assiduite for assi in assiduites: - self.count += 1 + # Ajout vérification workday + # (Si préférence mise après avoir déjà noté des assiduités) + if assi.date_debut.weekday() in self.non_work_days: + continue + + self.add_count(assi) + delta: timedelta = assi.date_fin - assi.date_debut if delta.days > 0: self.compute_long_assiduite(assi) - continue period: tuple[datetime, datetime] = (assi.date_debut, assi.date_fin) deb_date: date = assi.date_debut.date() if self.is_in_morning(period): - self.add_half_day(deb_date) + self.add_half_day(deb_date, assi) if self.is_in_evening(period): - self.add_half_day(deb_date, False) + self.add_half_day(deb_date, assi, False) - self.add_day(deb_date) + self.add_day(deb_date, assi) - self.hours += delta.total_seconds() / 3600 + self.add_hours(delta.total_seconds() / 3600, assi) + self.setup_data() - def to_dict(self) -> dict[str, int | float]: + 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])) + """ + for key in self.data: + self.data[key]["journee"] = len(self.data[key]["journee"]) + self.data[key]["demi"] = len(self.data[key]["demi"]) + + def to_dict(self, only_total: bool = True) -> dict[str, int | float]: """Retourne les métriques sous la forme d'un dictionnaire""" - return { - "compte": self.count, - "journee": len(self.days), - "demi": len(self.half_days), - "heure": round(self.hours, 2), - } + return self.data["total"] if only_total else self.data def get_assiduites_stats( @@ -211,55 +379,34 @@ def get_assiduites_stats( metrics: list[str] = metric.split(",") output: dict = {} calculator: CountCalculator = CountCalculator() + calculator.compute_assiduites(assiduites) if filtered is None or "split" not in filtered: - calculator.compute_assiduites(assiduites) - count: dict = calculator.to_dict() - + count: dict = calculator.to_dict(only_total=True) for key, val in count.items(): if key in metrics: output[key] = val return output if output else count + # Récupération des états etats: list[str] = ( filtered["etat"].split(",") if "etat" in filtered else ["absent", "present", "retard"] ) - + # Préparation du dictionnaire de retour avec les valeurs du calcul + count: dict = calculator.to_dict(only_total=False) for etat in etats: - output[etat] = _count_assiduites_etat(etat, assiduites, calculator, metrics) - if "est_just" not in filtered: - output[etat]["justifie"] = _count_assiduites_etat( - etat, assiduites, calculator, metrics, justifie=True - ) - + if etat != "present": + output[etat] = count[etat] + output[etat]["justifie"] = count[etat + "_just"] + output[etat]["non_justifie"] = count[etat + "_non_just"] + else: + output[etat] = count[etat] + output["total"] = count["total"] return output -def _count_assiduites_etat( - etat: str, - assiduites: Query, - calculator: CountCalculator, - metrics: list[str], - justifie: bool = False, -): # TODO type retour ? - # TODO documenter - calculator.reset() - etat_num: int = scu.EtatAssiduite.get(etat, -1) - assiduites_etat: Query = assiduites.filter(Assiduite.etat == etat_num) - if justifie: - assiduites_etat = assiduites_etat.filter(Assiduite.est_just == True) - - calculator.compute_assiduites(assiduites_etat) - count_etat: dict = calculator.to_dict() - output_etat: dict = {} - for key, val in count_etat.items(): - if key in metrics: - output_etat[key] = val - return output_etat if output_etat else count_etat - - def filter_assiduites_by_etat(assiduites: Assiduite, etat: str) -> Query: """ Filtrage d'une collection d'assiduites en fonction de leur état diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index ed126bce7..ede0698a8 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -655,6 +655,17 @@ class BasePreferences: "explanation": "Durée d'un créneau en heure. Utilisé dans les pages de saisie", }, ), + ( + "nb_heures_par_jour", + { + "initvalue": 8, + "size": 10, + "title": "Nombre d'heures de travail dans une journée", + "type": "int", + "explanation": "Est utilisé dans le calcul de la métrique 'heure'. ", + "category": "assi", + }, + ), ( "assi_etat_defaut", { diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 7f23f8465..5e2dae80f 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -238,6 +238,47 @@ class EtatJustificatif(int, BiDirectionalEnum): return etat in cls._value2member_map_ +class NonWorkDays(int, BiDirectionalEnum): + """Correspondance entre les jours et les numéros de jours""" + + LUN = 0 + MAR = 1 + MER = 2 + JEU = 3 + VEN = 4 + SAM = 5 + DIM = 6 + + @classmethod + def get_all_non_work_days( + 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 + puis renvoie une liste BiDirectionnalEnum NonWorkDays + + Example: + 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é") + + Args: + formsemestre_id (int, optional): id d'un formsemestre . Defaults to None. + dept_id (int, optional): id d'un départment. Defaults to None. + + Returns: + list[NonWorkDays]: La liste des NonWorkDays en version BiDirectionnalEnum + """ + from app.scodoc import sco_preferences + + return [ + cls.get(day.strip()) + for day in sco_preferences.get_preference( + "non_travail", formsemestre_id=formsemestre_id, dept_id=dept_id + ).split(",") + ] + + def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: """ Vérifie si une date est au format iso diff --git a/app/templates/assiduites/pages/config_assiduites.j2 b/app/templates/assiduites/pages/config_assiduites.j2 index c3b86b8b6..a0203d70d 100644 --- a/app/templates/assiduites/pages/config_assiduites.j2 +++ b/app/templates/assiduites/pages/config_assiduites.j2 @@ -79,10 +79,10 @@ c'est à dire à la montre des étudiants. {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} - {{ wtf.form_field(form.morning_time) }} - {{ wtf.form_field(form.lunch_time) }} - {{ wtf.form_field(form.afternoon_time) }} - {{ wtf.form_field(form.tick_time) }} + {{ wtf.form_field(form.assi_morning_time) }} + {{ wtf.form_field(form.assi_lunch_time) }} + {{ wtf.form_field(form.assi_afternoon_time) }} + {{ wtf.form_field(form.assi_tick_time) }}
diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 44ca2ba8a..243399f92 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -348,8 +348,8 @@ def _get_dates_from_assi_form( Ramène ok=True si ok. Met des messages d'erreur dans le form. """ - debut_jour = "00:00" - fin_jour = "23:59:59" + debut_jour = ScoDocSiteConfig.get("assi_morning_time", "08:00") + fin_jour = ScoDocSiteConfig.get("assi_afternoon_time", "17:00") date_fin = None # On commence par convertir individuellement tous les champs try: diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 619492e7f..720007eb1 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -337,15 +337,19 @@ def config_assiduites(): ("edt_ics_user_path", "Chemin vers les ics des enseignants"), ) + assi_options = ( + ("assi_morning_time", "Heure du début de la journée"), + ("assi_lunch_time", "Heure du midi"), + ("assi_afternoon_time", "Heure du fin de la journée"), + ("assi_tick_time", "Granularité de la timeline"), + ) + if form.validate_on_submit(): - if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]): - flash("Heure du début de la journée enregistrée") - if ScoDocSiteConfig.set("assi_lunch_time", form.data["lunch_time"]): - flash("Heure de midi enregistrée") - if ScoDocSiteConfig.set("assi_afternoon_time", form.data["afternoon_time"]): - flash("Heure de fin de la journée enregistrée") - if ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])): - flash("Granularité de la timeline enregistrée") + # --- Options assiduités + for opt_name, message in assi_options: + if ScoDocSiteConfig.set(opt_name, form.data[opt_name]): + flash(f"{message} enregistrée") + # --- Calendriers emploi du temps for opt_name, message in edt_options: if ScoDocSiteConfig.set(opt_name, form.data[opt_name]): @@ -354,19 +358,21 @@ def config_assiduites(): return redirect(url_for("scodoc.configuration")) if request.method == "GET": - form.morning_time.data = ScoDocSiteConfig.get( + form.assi_morning_time.data = ScoDocSiteConfig.get( "assi_morning_time", datetime.time(8, 0, 0) ) - form.lunch_time.data = ScoDocSiteConfig.get( + form.assi_lunch_time.data = ScoDocSiteConfig.get( "assi_lunch_time", datetime.time(13, 0, 0) ) - form.afternoon_time.data = ScoDocSiteConfig.get( + form.assi_afternoon_time.data = ScoDocSiteConfig.get( "assi_afternoon_time", datetime.time(18, 0, 0) ) try: - form.tick_time.data = float(ScoDocSiteConfig.get("assi_tick_time", 15.0)) + form.assi_tick_time.data = float( + ScoDocSiteConfig.get("assi_tick_time", 15.0) + ) except ValueError: - form.tick_time.data = 15.0 + form.assi_tick_time.data = 15.0 ScoDocSiteConfig.set("assi_tick_time", 15.0) # --- Emplois du temps for opt_name, _ in edt_options: