From a05801b78a8f324dba04a75fb1142e26a403ee24 Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 11 Jan 2024 17:19:56 +0100 Subject: [PATCH] Assiduites : calendrier python fixes #812 --- app/models/assiduites.py | 13 + app/templates/assiduites/pages/calendrier2.j2 | 596 ++++++++++++++++++ .../assiduites/widgets/assiduite_bubble.j2 | 7 + .../assiduites/widgets/minitimeline_simple.j2 | 7 + app/views/assiduites.py | 392 +++++++++++- 5 files changed, 999 insertions(+), 16 deletions(-) create mode 100644 app/templates/assiduites/pages/calendrier2.j2 create mode 100644 app/templates/assiduites/widgets/assiduite_bubble.j2 create mode 100644 app/templates/assiduites/widgets/minitimeline_simple.j2 diff --git a/app/models/assiduites.py b/app/models/assiduites.py index ccfdd81e9..76ec9ea18 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -338,6 +338,19 @@ class Assiduite(ScoDocModel): return "Non spécifié" if traduire else None + def get_saisie(self) -> str: + """ + retourne le texte "saisie le par " + """ + + date: str = self.entry_date.strftime("%d/%m/%Y à %H:%M") + utilisateur: str = "" + if self.user != None: + self.user: User + utilisateur = f"par {self.user.get_prenomnom()}" + + return f"saisie le {date} {utilisateur}" + class Justificatif(ScoDocModel): """ diff --git a/app/templates/assiduites/pages/calendrier2.j2 b/app/templates/assiduites/pages/calendrier2.j2 new file mode 100644 index 000000000..3eaedb52b --- /dev/null +++ b/app/templates/assiduites/pages/calendrier2.j2 @@ -0,0 +1,596 @@ +{% block pageContent %} +{% include "assiduites/widgets/alert.j2" %} + +
+

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 %} +
+ {% endif %} + {% if mode_demi %} + {% if not jour.is_non_work() %} + {{jour.get_nom()}} + + + {% endif %} + {% else %} + {% 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}} +
+
+ + {% endif %} +
+ + {% endfor %} +
+
+ {% endfor %} +
+
+ Année scolaire 2022-2023Changer + année: + + + 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é +
  • + +
  • → 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
  • +
  • justificatif non valide +
  • +
+
+ + + + +{% endblock pageContent %} diff --git a/app/templates/assiduites/widgets/assiduite_bubble.j2 b/app/templates/assiduites/widgets/assiduite_bubble.j2 new file mode 100644 index 000000000..3db6ea25d --- /dev/null +++ b/app/templates/assiduites/widgets/assiduite_bubble.j2 @@ -0,0 +1,7 @@ +
+
{{moduleimpl}}
+
{{date_debut}}
+
{{date_fin}}
+
État: {{etat}}
+
{{saisie}}
+
\ No newline at end of file diff --git a/app/templates/assiduites/widgets/minitimeline_simple.j2 b/app/templates/assiduites/widgets/minitimeline_simple.j2 new file mode 100644 index 000000000..5842505f8 --- /dev/null +++ b/app/templates/assiduites/widgets/minitimeline_simple.j2 @@ -0,0 +1,7 @@ +
+{% for assi in assi_blocks %} +
+ {{assi.bubble | safe }} +
+{% endfor %} +
\ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 73f997901..a6108af25 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -80,6 +80,7 @@ from app.scodoc.sco_exceptions import ScoValueError from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids from app.scodoc.sco_archives_justificatifs import JustificatifArchiver +from flask_sqlalchemy.query import Query CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS @@ -837,19 +838,11 @@ def calendrier_assi_etud(): if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") - # Préparation de la page - header: str = html_sco_header.sco_header( - page_title="Calendrier de l'assiduité", - init_qtip=True, - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) + # Options + mode_demi: bool = scu.to_bool(request.args.get("mode_demi", "t")) + show_pres: bool = scu.to_bool(request.args.get("show_pres", "f")) + show_reta: bool = scu.to_bool(request.args.get("show_reta", "f")) + annee: int = int(request.args.get("annee", scu.annee_scolaire())) # Récupération des années d'étude de l'étudiant annees: list[int] = [] @@ -866,16 +859,34 @@ def calendrier_assi_etud(): annees_str += f"{ann}," annees_str += "]" + # Préparation de la page + header: str = html_sco_header.sco_header( + page_title="Calendrier de l'assiduité", + init_qtip=True, + javascripts=[ + "js/assiduites.js", + "js/date_utils.js", + ], + cssstyles=CSSSTYLES + + [ + "css/assiduites.css", + ], + ) + + calendrier = generate_calendar(etud, annee) # Peuplement du template jinja return HTMLBuilder( header, render_template( - "assiduites/pages/calendrier.j2", + "assiduites/pages/calendrier2.j2", sco=ScoData(etud), - annee=scu.annee_scolaire(), + annee=annee, nonworkdays=_non_work_days(), - minitimeline=_mini_timeline(), annees=annees_str, + calendrier=calendrier, + mode_demi=mode_demi, + show_pres=show_pres, + show_reta=show_reta, ), ).build() @@ -2239,3 +2250,352 @@ def _get_etuds_dem_def(formsemestre) -> str: ) + "}" ) + + +# --- Gestion du calendrier --- + + +def generate_calendar( + etudiant: Identite, + annee: int = None, +): + # Si pas d'année alors on prend l'année scolaire en cours + if annee is None: + annee = scu.annee_scolaire() + + # On prend du 01/09 au 31/08 + date_debut: datetime.datetime = datetime.datetime(annee, 9, 1, 0, 0) + date_fin: datetime.datetime = datetime.datetime(annee + 1, 8, 31, 23, 59) + + # Filtrage des assiduités et des justificatifs en fonction de la periode / année + etud_assiduites: Query = scass.filter_by_date( + etudiant.assiduites, + Assiduite, + date_deb=date_debut, + date_fin=date_fin, + ) + etud_justificatifs: Query = scass.filter_by_date( + etudiant.justificatifs, + Justificatif, + date_deb=date_debut, + date_fin=date_fin, + ) + + # Récupération des jours de l'année et de leurs assiduités/justificatifs + annee_par_mois: dict[int, list[datetime.date]] = _organize_by_month( + _get_dates_between( + deb=date_debut.date(), + fin=date_fin.date(), + ), + etud_assiduites, + etud_justificatifs, + ) + + return annee_par_mois + + +WEEKDAYS = { + 0: "Lun ", + 1: "Mar ", + 2: "Mer ", + 3: "Jeu ", + 4: "Ven ", + 5: "Sam ", + 6: "Dim ", +} + +MONTHS = { + 1: "Janv.", + 2: "Févr.", + 3: "Mars", + 4: "Avr.", + 5: "Mai", + 6: "Juin", + 7: "Juil.", + 8: "Août", + 9: "Sept.", + 10: "Oct.", + 11: "Nov.", + 12: "Déc.", +} + + +class Jour: + """Jour + Jour du calendrier + get_nom : retourne le numéro et le nom du Jour (ex: M19 / Mer 19) + """ + + def __init__(self, date: datetime.date, assiduites: Query, justificatifs: Query): + self.date = date + self.assiduites = assiduites + self.justificatifs = justificatifs + + def get_nom(self, mode_demi: bool = True) -> str: + str_jour: str = WEEKDAYS.get(self.date.weekday()) + return f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour}{self.date.day}" + + def get_date(self) -> str: + return self.date.strftime("%d/%m/%Y") + + def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str: + etat = "" + est_just = "" + + if self.is_non_work(): + return "color nonwork" + + etat = self._get_color_assiduites_cascade( + self._get_etats_from_assiduites(self.assiduites), + show_pres=show_pres, + show_reta=show_reta, + ) + + est_just = self._get_color_justificatifs_cascade( + self._get_etats_from_justificatifs(self.justificatifs), + ) + + return f"color {etat} {est_just}" + + 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) + STR_TIME = lambda x: datetime.time(*list(map(int, x.split(":")))) + + heure_midi = STR_TIME(ScoDocSiteConfig.get("assi_lunch_time", "13:00")) + + if matin: + heure_matin = STR_TIME(ScoDocSiteConfig.get("assi_morning_time", "08:00")) + matin = ( + # date debut + scu.localize_datetime( + datetime.datetime.combine(self.date, heure_matin) + ), + # date fin + scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)), + ) + assiduites_matin = [ + assi + for assi in self.assiduites + if scu.is_period_overlapping((assi.date_debut, assi.date_fin), matin) + ] + justificatifs_matin = [ + justi + for justi in self.justificatifs + if scu.is_period_overlapping((justi.date_debut, justi.date_fin), matin) + ] + + etat = self._get_color_assiduites_cascade( + self._get_etats_from_assiduites(assiduites_matin), + show_pres=show_pres, + show_reta=show_reta, + ) + + est_just = self._get_color_justificatifs_cascade( + self._get_etats_from_justificatifs(justificatifs_matin), + ) + + return f"color {etat} {est_just}" + + heure_soir = STR_TIME(ScoDocSiteConfig.get("assi_afternoon_time", "17:00")) + + # séparation en demi journées + aprem = ( + # date debut + scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)), + # date fin + scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)), + ) + + assiduites_aprem = [ + assi + for assi in self.assiduites + if scu.is_period_overlapping((assi.date_debut, assi.date_fin), aprem) + ] + + justificatifs_aprem = [ + justi + for justi in self.justificatifs + if scu.is_period_overlapping((justi.date_debut, justi.date_fin), aprem) + ] + + etat = self._get_color_assiduites_cascade( + self._get_etats_from_assiduites(assiduites_aprem), + show_pres=show_pres, + show_reta=show_reta, + ) + + est_just = self._get_color_justificatifs_cascade( + self._get_etats_from_justificatifs(justificatifs_aprem), + ) + + return f"color {etat} {est_just}" + + def has_assiduites(self) -> bool: + return self.assiduites.count() > 0 + + def generate_minitimeline(self) -> str: + # Récupérer le référenciel de la timeline + STR_TIME = lambda x: _time_to_timedelta( + datetime.time(*list(map(int, x.split(":")))) + ) + + heure_matin: datetime.timedelta = STR_TIME( + ScoDocSiteConfig.get("assi_morning_time", "08:00") + ) + heure_midi: datetime.timedelta = STR_TIME( + ScoDocSiteConfig.get("assi_lun_time", "13:00") + ) + heure_soir: datetime.timedelta = STR_TIME( + ScoDocSiteConfig.get("assi_afternoon_time", "17:00") + ) + # longueur_timeline = heure_soir - heure_matin + longueur_timeline: datetime.timedelta = heure_soir - heure_matin + + # chaque block d'assiduité est défini par: + # longueur = ( (fin-deb) / longueur_timeline ) * 100 + # emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100 + + assiduite_blocks: list[dict[str, float | str]] = [] + + for assi in self.assiduites: + deb: datetime.timedelta = _time_to_timedelta( + assi.date_debut.time() + if assi.date_debut.date() == self.date + else heure_matin + ) + fin: datetime.timedelta = _time_to_timedelta( + assi.date_fin.time() + if assi.date_fin.date() == self.date + else heure_soir + ) + + longueur: float = ((fin - deb) / longueur_timeline) * 100 + emplacement: float = ((deb - heure_matin) / longueur_timeline) * 100 + etat: str = scu.EtatAssiduite(assi.etat).name.lower() + est_just: str = "est_just" if assi.est_just else "" + + assiduite_blocks.append( + { + "longueur": longueur, + "emplacement": emplacement, + "etat": etat, + "est_just": est_just, + "bubble": _generate_assiduite_bubble(assi), + "id": assi.assiduite_id, + } + ) + + return render_template( + "assiduites/widgets/minitimeline_simple.j2", + assi_blocks=assiduite_blocks, + ) + + def is_non_work(self): + 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])) + + def _get_etats_from_justificatifs( + self, justificatifs: Query + ) -> list[scu.EtatJustificatif]: + return list(set([scu.EtatJustificatif(justi.etat) for justi in justificatifs])) + + def _get_color_assiduites_cascade( + self, + etats: list[scu.EtatAssiduite], + show_pres: bool = False, + show_reta: bool = False, + ) -> str: + if scu.EtatAssiduite.ABSENT in etats: + return "absent" + if scu.EtatAssiduite.RETARD in etats and show_reta: + return "retard" + if scu.EtatAssiduite.PRESENT in etats and show_pres: + return "present" + + return "sans_etat" + + def _get_color_justificatifs_cascade( + self, + etats: list[scu.EtatJustificatif], + ) -> str: + if scu.EtatJustificatif.VALIDE in etats: + return "est_just" + if scu.EtatJustificatif.ATTENTE in etats: + return "attente" + if scu.EtatJustificatif.MODIFIE in etats: + return "modifie" + if scu.EtatJustificatif.NON_VALIDE in etats: + return "invalide" + + return "" + + +def _get_dates_between(deb: datetime.date, fin: datetime.date) -> list[datetime.date]: + resultat = [] + date_actuelle = deb + while date_actuelle <= fin: + resultat.append(date_actuelle) + date_actuelle += datetime.timedelta(days=1) + return resultat + + +def _organize_by_month(days, assiduites, justificatifs): + """ + Organiser les dates par mois. + """ + organized = {} + for date in days: + # Utiliser le numéro du mois comme clé + month = MONTHS.get(date.month) + # Ajouter le jour à la liste correspondante au mois + if month not in organized: + organized[month] = [] + + date_assiduites: Query = scass.filter_by_date( + assiduites, + Assiduite, + date_deb=datetime.datetime.combine(date, datetime.time(0, 0)), + date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)), + ) + + date_justificatifs: Query = scass.filter_by_date( + justificatifs, + Justificatif, + date_deb=datetime.datetime.combine(date, datetime.time(0, 0)), + date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)), + ) + # On génère un `Jour` composé d'une date, et des assiduités/justificatifs du jour + jour: Jour = Jour(date, date_assiduites, date_justificatifs) + + organized[month].append(jour) + + return organized + + +def _time_to_timedelta(t: datetime.time) -> datetime.timedelta: + if isinstance(t, datetime.timedelta): + return t + return datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second) + + +def _generate_assiduite_bubble(assiduite: Assiduite) -> str: + # Récupérer informations modules impl + moduleimpl_infos: str = assiduite.get_module(traduire=True) + + # Récupérer informations saisie + saisie: str = assiduite.get_saisie() + + return render_template( + "assiduites/widgets/assiduite_bubble.j2", + moduleimpl=moduleimpl_infos, + etat=scu.EtatAssiduite(assiduite.etat).name.lower(), + date_debut=assiduite.date_debut.strftime("%d/%m/%Y %H:%M"), + date_fin=assiduite.date_fin.strftime("%d/%m/%Y %H:%M"), + saisie=saisie, + )