diff --git a/app/models/__init__.py b/app/models/__init__.py index 39a8d3e2..032ddc86 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -81,3 +81,5 @@ from app.models.but_refcomp import ( from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models.config import ScoDocSiteConfig + +from app.models.assiduites import Assiduite, Justificatif diff --git a/app/models/assiduites.py b/app/models/assiduites.py new file mode 100644 index 00000000..7471c11e --- /dev/null +++ b/app/models/assiduites.py @@ -0,0 +1,343 @@ +# -*- coding: UTF-8 -* +"""Gestion de l'assiduité (assiduités + justificatifs) +""" +from datetime import datetime + +from app import db +from app.models import ModuleImpl +from app.models.etudiants import Identite +from app.scodoc.sco_utils import ( + EtatAssiduite, + EtatJustificatif, + localize_datetime, + is_period_overlapping, +) +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_utils import ( + EtatAssiduite, + EtatJustificatif, + localize_datetime, +) + + +class Assiduite(db.Model): + """ + Représente une assiduité: + - une plage horaire lié à un état et un étudiant + - un module si spécifiée + - une description si spécifiée + """ + + __tablename__ = "assiduites" + + id = db.Column(db.Integer, primary_key=True, nullable=False) + assiduite_id = db.synonym("id") + + date_debut = db.Column( + db.DateTime(timezone=True), server_default=db.func.now(), nullable=False + ) + date_fin = db.Column( + db.DateTime(timezone=True), server_default=db.func.now(), nullable=False + ) + + moduleimpl_id = db.Column( + db.Integer, + db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"), + ) + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + etat = db.Column(db.Integer, nullable=False) + + desc = db.Column(db.Text) + + entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + ) + + est_just = db.Column(db.Boolean, server_default="false", nullable=False) + + def to_dict(self, format_api=True) -> dict: + """Retourne la représentation json de l'assiduité""" + etat = self.etat + + if format_api: + etat = EtatAssiduite.inverse().get(self.etat).name + data = { + "assiduite_id": self.id, + "etudid": self.etudid, + "moduleimpl_id": self.moduleimpl_id, + "date_debut": self.date_debut, + "date_fin": self.date_fin, + "etat": etat, + "desc": self.desc, + "entry_date": self.entry_date, + "user_id": self.user_id, + "est_just": self.est_just, + } + return data + + @classmethod + def create_assiduite( + cls, + etud: Identite, + date_debut: datetime, + date_fin: datetime, + etat: EtatAssiduite, + moduleimpl: ModuleImpl = None, + description: str = None, + entry_date: datetime = None, + user_id: int = None, + est_just: bool = False, + ) -> object or int: + """Créer une nouvelle assiduité pour l'étudiant""" + # Vérification de non duplication des périodes + assiduites: list[Assiduite] = etud.assiduites + if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite): + raise ScoValueError( + "Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)" + ) + if moduleimpl is not None: + # Vérification de l'existence du module pour l'étudiant + if moduleimpl.est_inscrit(etud): + nouv_assiduite = Assiduite( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudiant=etud, + moduleimpl_id=moduleimpl.id, + desc=description, + entry_date=entry_date, + user_id=user_id, + est_just=est_just, + ) + else: + raise ScoValueError("L'étudiant n'est pas inscrit au moduleimpl") + else: + nouv_assiduite = Assiduite( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudiant=etud, + desc=description, + entry_date=entry_date, + user_id=user_id, + est_just=est_just, + ) + + return nouv_assiduite + + @classmethod + def fast_create_assiduite( + cls, + etudid: int, + date_debut: datetime, + date_fin: datetime, + etat: EtatAssiduite, + moduleimpl_id: int = None, + description: str = None, + entry_date: datetime = None, + est_just: bool = False, + ) -> object or int: + """Créer une nouvelle assiduité pour l'étudiant""" + # Vérification de non duplication des périodes + + nouv_assiduite = Assiduite( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudid=etudid, + moduleimpl_id=moduleimpl_id, + desc=description, + entry_date=entry_date, + est_just=est_just, + ) + + return nouv_assiduite + + +class Justificatif(db.Model): + """ + Représente un justificatif: + - une plage horaire lié à un état et un étudiant + - une raison si spécifiée + - un fichier si spécifié + """ + + __tablename__ = "justificatifs" + + id = db.Column(db.Integer, primary_key=True) + justif_id = db.synonym("id") + + date_debut = db.Column( + db.DateTime(timezone=True), server_default=db.func.now(), nullable=False + ) + date_fin = db.Column( + db.DateTime(timezone=True), server_default=db.func.now(), nullable=False + ) + + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + etat = db.Column( + db.Integer, + nullable=False, + ) + + entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + raison = db.Column(db.Text()) + + # Archive_id -> sco_archives_justificatifs.py + fichier = db.Column(db.Text()) + + def to_dict(self, format_api: bool = False) -> dict: + """transformation de l'objet en dictionnaire sérialisable""" + + etat = self.etat + + if format_api: + etat = EtatJustificatif.inverse().get(self.etat).name + + data = { + "justif_id": self.justif_id, + "etudid": self.etudid, + "date_debut": self.date_debut, + "date_fin": self.date_fin, + "etat": etat, + "raison": self.raison, + "fichier": self.fichier, + "entry_date": self.entry_date, + "user_id": self.user_id, + } + return data + + @classmethod + def create_justificatif( + cls, + etud: Identite, + date_debut: datetime, + date_fin: datetime, + etat: EtatJustificatif, + raison: str = None, + entry_date: datetime = None, + user_id: int = None, + ) -> object or int: + """Créer un nouveau justificatif pour l'étudiant""" + nouv_justificatif = Justificatif( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudiant=etud, + raison=raison, + entry_date=entry_date, + user_id=user_id, + ) + return nouv_justificatif + + @classmethod + def fast_create_justificatif( + cls, + etudid: int, + date_debut: datetime, + date_fin: datetime, + etat: EtatJustificatif, + raison: str = None, + entry_date: datetime = None, + ) -> object or int: + """Créer un nouveau justificatif pour l'étudiant""" + + nouv_justificatif = Justificatif( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudid=etudid, + raison=raison, + entry_date=entry_date, + ) + + return nouv_justificatif + + +def is_period_conflicting( + date_debut: datetime, + date_fin: datetime, + collection: list[Assiduite or Justificatif], + collection_cls: Assiduite or Justificatif, +) -> bool: + """ + Vérifie si une date n'entre pas en collision + avec les justificatifs ou assiduites déjà présentes + """ + + date_debut = localize_datetime(date_debut) + date_fin = localize_datetime(date_fin) + + if ( + collection.filter_by(date_debut=date_debut, date_fin=date_fin).first() + is not None + ): + return True + + count: int = collection.filter( + collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut + ).count() + + return count > 0 + + +def compute_assiduites_justified( + justificatifs: Justificatif = Justificatif, reset: bool = False +) -> list[int]: + """Calcule et modifie les champs "est_just" de chaque assiduité lié à l'étud + retourne la liste des assiduite_id justifiées + + Si reset alors : met à false toutes les assiduités non justifiées par les justificatifs donnés + """ + + list_assiduites_id: set[int] = set() + for justi in justificatifs: + assiduites: Assiduite = ( + Assiduite.query.join(Justificatif, Justificatif.etudid == Assiduite.etudid) + .filter(Assiduite.etat != EtatAssiduite.PRESENT) + .filter( + Assiduite.date_debut <= justi.date_fin, + Assiduite.date_fin >= justi.date_debut, + ) + ) + + for assi in assiduites: + assi.est_just = True + list_assiduites_id.add(assi.id) + db.session.add(assi) + + if reset: + un_justified: Assiduite = ( + Assiduite.query.filter(Assiduite.id.not_in(list_assiduites_id)) + .filter(Assiduite.etat != EtatAssiduite.PRESENT) + .join(Justificatif, Justificatif.etudid == Assiduite.etudid) + ) + + for assi in un_justified: + assi.est_just = False + db.session.add(assi) + + db.session.commit() + return list(list_assiduites_id) diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 0f5f59c2..fb20313f 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -73,6 +73,10 @@ class Identite(db.Model): passive_deletes=True, ) + # Relations avec les assiduites et les justificatifs + assiduites = db.relationship("Assiduite", backref="etudiant", lazy="dynamic") + justificatifs = db.relationship("Justificatif", backref="etudiant", lazy="dynamic") + def __repr__(self): return ( f"" diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 8a7dcb01..c4d7c3fe 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -122,6 +122,22 @@ class ModuleImpl(db.Model): raise AccessDenied(f"Modification impossible pour {user}") return False + def est_inscrit(self, etud: Identite) -> bool: + """ + Vérifie si l'étudiant est bien inscrit au moduleimpl + + Retourne Vrai si c'est le cas, faux sinon + """ + + is_module: int = ( + ModuleImplInscription.query.filter_by( + etudid=etud.id, moduleimpl_id=self.id + ).count() + > 0 + ) + + return is_module + # Enseignants (chargés de TD ou TP) d'un moduleimpl notes_modules_enseignants = db.Table( diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 79313e6b..dbcdfffa 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -32,13 +32,14 @@ import base64 import bisect import collections import datetime -from enum import IntEnum +from enum import IntEnum, Enum import io import json from hashlib import md5 import numbers import os import re +from shutil import get_terminal_size import _thread import time import unicodedata @@ -50,6 +51,10 @@ from PIL import Image as PILImage import pydot import requests +from pytz import timezone + +import dateutil.parser as dtparser + import flask from flask import g, request, Response from flask import flash, url_for, make_response @@ -91,6 +96,161 @@ ETATS_INSCRIPTION = { } +def print_progress_bar( + iteration, + total, + prefix="", + suffix="", + finish_msg="", + decimals=1, + length=100, + fill="█", + autosize=False, +): + """ + Affiche une progress bar à un point donné (mettre dans une boucle pour rendre dynamique) + @params: + iteration - Required : index du point donné (Int) + total - Required : nombre total avant complétion (eg: len(List)) + prefix - Optional : Préfix -> écrit à gauche de la barre (Str) + suffix - Optional : Suffix -> écrit à droite de la barre (Str) + 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) + """ + percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) + color = TerminalColor.RED + if 50 >= float(percent) > 25: + color = TerminalColor.MAGENTA + if 75 >= float(percent) > 50: + color = TerminalColor.BLUE + if 90 >= float(percent) > 75: + color = TerminalColor.CYAN + if 100 >= float(percent) > 90: + color = TerminalColor.GREEN + styling = f"{prefix} |{fill}| {percent}% {suffix}" + if autosize: + cols, _ = get_terminal_size(fallback=(length, 1)) + length = cols - len(styling) + filled_length = int(length * iteration // total) + pg_bar = fill * filled_length + "-" * (length - filled_length) + print(f"\r{color}{styling.replace(fill, pg_bar)}{TerminalColor.RESET}", end="\r") + # Affiche une nouvelle ligne vide + if iteration == total: + print(f"\n{finish_msg}") + + +class TerminalColor: + """Ensemble de couleur pour terminaux""" + + BLUE = "\033[94m" + CYAN = "\033[96m" + GREEN = "\033[92m" + MAGENTA = "\033[95m" + RED = "\033[91m" + RESET = "\033[0m" + + +class BiDirectionalEnum(Enum): + """Permet la recherche inverse d'un enum + Condition : les clés et les valeurs doivent être uniques + les clés doivent être en MAJUSCULES + """ + + @classmethod + def contains(cls, attr: str): + """Vérifie sur un attribut existe dans l'enum""" + return attr.upper() in cls._member_names_ + + @classmethod + def get(cls, attr: str, default: any = None): + """Récupère une valeur à partir de son attribut""" + val = None + try: + val = cls[attr.upper()] + except (KeyError, AttributeError): + val = default + return val + + @classmethod + def inverse(cls): + """Retourne un dictionnaire représentant la map inverse de l'Enum""" + return cls._value2member_map_ + + +class EtatAssiduite(int, BiDirectionalEnum): + """Code des états d'assiduité""" + + # Stockés en BD ne pas modifier + + PRESENT = 0 + RETARD = 1 + ABSENT = 2 + + +class EtatJustificatif(int, BiDirectionalEnum): + """Code des états des justificatifs""" + + # Stockés en BD ne pas modifier + + VALIDE = 0 + NON_VALIDE = 1 + ATTENTE = 2 + MODIFIE = 3 + + +def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: + """ + Vérifie si une date est au format iso + + Retourne un booléen Vrai (ou un objet Datetime si convert = True) + si l'objet est au format iso + + Retourne Faux si l'objet n'est pas au format et convert = False + + Retourne None sinon + """ + + try: + date: datetime.datetime = dtparser.isoparse(date) + return date if convert else True + except (dtparser.ParserError, ValueError, TypeError): + return None if convert else False + + +def localize_datetime(date: datetime.datetime or str) -> datetime.datetime: + """Ajoute un timecode UTC à la date donnée.""" + if isinstance(date, str): + date = is_iso_formated(date, convert=True) + + new_date: datetime.datetime = date + if new_date.tzinfo is None: + try: + new_date = timezone("Europe/Paris").localize(date) + except OverflowError: + new_date = timezone("UTC").localize(date) + return new_date + + +def is_period_overlapping( + periode: tuple[datetime.datetime, datetime.datetime], + interval: tuple[datetime.datetime, datetime.datetime], + bornes: bool = True, +) -> bool: + """ + Vérifie si la période et l'interval s'intersectent + si strict == True : les extrémitées ne comptes pas + Retourne Vrai si c'est le cas, faux sinon + """ + p_deb, p_fin = periode + i_deb, i_fin = interval + + if bornes: + return p_deb <= i_fin and p_fin >= i_deb + return p_deb < i_fin and p_fin > i_deb + + # Types de modules class ModuleType(IntEnum): """Code des types de module."""