# -*- coding: UTF-8 -* """Gestion de l'assiduité (assiduités + justificatifs)""" from datetime import datetime from flask_login import current_user from flask_sqlalchemy.query import Query from app import db, log, g, set_sco_dept from app.models import ( ModuleImpl, Module, Scolog, FormSemestre, FormSemestreInscription, ScoDocModel, ) from app.models.etudiants import Identite from app.auth.models import User from app.scodoc import sco_abs_notification from app.scodoc.sco_archives_justificatifs import JustificatifArchiver from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission from app.scodoc import sco_preferences from app.scodoc import sco_utils as scu from app.scodoc.sco_utils import ( EtatAssiduite, EtatJustificatif, localize_datetime, is_assiduites_module_forced, NonWorkDays, ) class Assiduite(ScoDocModel): """ 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) description = 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) external_data = db.Column(db.JSON, nullable=True) # Déclare la relation "joined" car on va très souvent vouloir récupérer # l'étudiant en même tant que l'assiduité (perf.: évite nouvelle requete SQL) etudiant = db.relationship("Identite", back_populates="assiduites", lazy="joined") # En revanche, user est rarement accédé: user = db.relationship( "User", backref=db.backref( "assiduites", lazy="select", order_by="Assiduite.entry_date" ), 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. """ etat = self.etat user: User | None = None if format_api: # format api utilise les noms "present,absent,retard" au lieu des int etat = EtatAssiduite.inverse().get(self.etat).name if self.user_id is not None: user = db.session.get(User, self.user_id) data = { "assiduite_id": self.id, "etudid": self.etudid, "code_nip": self.etudiant.code_nip, "moduleimpl_id": self.moduleimpl_id, "date_debut": self.date_debut, "date_fin": self.date_fin, "etat": etat, "desc": self.description, "entry_date": self.entry_date, "user_id": None if user is None else user.id, # l'uid "user_name": None if user is None else user.user_name, # le login "user_nom_complet": ( None if user is None else user.get_nomcomplet() ), # "Marie Dupont" "est_just": self.est_just, "external_data": self.external_data, } return data def __str__(self) -> str: "chaine pour journaux et debug (lisible par humain français)" try: etat_str = EtatAssiduite(self.etat).name.lower().capitalize() except ValueError: etat_str = "Invalide" return f"""{etat_str} { "just." if self.est_just else "non just." } de { self.date_debut.strftime("%d/%m/%Y %Hh%M") } à { self.date_fin.strftime("%d/%m/%Y %Hh%M") }""" @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, external_data: dict = None, notify_mail=False, ) -> "Assiduite": """Créer une nouvelle assiduité pour l'étudiant. Les datetime doivent être en timezone serveur. Raises ScoValueError en cas de conflit ou erreur. """ if date_debut.tzinfo is None: log( f"Warning: create_assiduite: date_debut without timezone ({date_debut})" ) 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 formsemestre_date_debut: FormSemestre = get_formsemestre_from_data( { "etudid": etud.id, "date_debut": date_debut, "date_fin": date_debut, } ) formsemestre_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=formsemestre_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=formsemestre_date_fin ): raise ScoValueError("La date de fin n'est pas un jour travaillé") # Vérification de l'activation du module if (err_msg := has_assiduites_disable_pref(formsemestre_date_debut)) or ( err_msg := has_assiduites_disable_pref(formsemestre_date_fin) ): raise ScoValueError(err_msg) # Vérification de non duplication des périodes assiduites: Query = etud.assiduites if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite): log( f"""create_assiduite: period_conflicting etudid={etud.id} date_debut={ date_debut} date_fin={date_fin}""" ) raise ScoValueError( "Duplication: la période rentre en conflit avec une plage enregistrée" ) if not est_just: est_just = ( len( get_justifs_from_date(etud.etudid, date_debut, date_fin, valid=True) ) > 0 ) moduleimpl_id = None if moduleimpl is not None: # Vérification de l'inscription de l'étudiant if moduleimpl.est_inscrit(etud): moduleimpl_id = moduleimpl.id else: raise ScoValueError("L'étudiant n'est pas inscrit au module") elif not ( external_data is not None and external_data.get("module") is not None ): # Vérification si module forcé formsemestre: FormSemestre = get_formsemestre_from_data( {"etudid": etud.id, "date_debut": date_debut, "date_fin": date_fin} ) force: bool if formsemestre: force = is_assiduites_module_forced(formsemestre_id=formsemestre.id) else: force = is_assiduites_module_forced(dept_id=etud.dept_id) if force: raise ScoValueError("Module non renseigné") nouv_assiduite = Assiduite( date_debut=date_debut, date_fin=date_fin, description=description, entry_date=entry_date, est_just=est_just, etat=etat, etudiant=etud, external_data=external_data, moduleimpl_id=moduleimpl_id, user_id=user_id, ) db.session.add(nouv_assiduite) db.session.flush() log(f"create_assiduite: {etud.id} id={nouv_assiduite.id} {nouv_assiduite}") Scolog.logdb( method="create_assiduite", etudid=etud.id, msg=f"assiduité: {nouv_assiduite}", ) if notify_mail and etat == EtatAssiduite.ABSENT: sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut) return nouv_assiduite def set_moduleimpl(self, moduleimpl_id: int | str): """Mise à jour du moduleimpl_id Les valeurs du champ "moduleimpl_id" possibles sont : - (un id classique) - ("autre" ou "") - "" (pas de moduleimpl_id) Si la valeur est "autre" il faut: - mettre à None assiduité.moduleimpl_id - mettre à jour assiduite.external_data["module"] = "autre" En fonction de la configuration du semestre (option force_module) la valeur "" peut-être considérée comme invalide. - Il faudra donc vérifier que ce n'est pas le cas avant de mettre à jour l'assiduité """ moduleimpl: ModuleImpl = None if moduleimpl_id == "autre": # Configuration de external_data pour Module Autre # Si self.external_data None alors on créé un dictionnaire {"module": "autre"} # Sinon on met à jour external_data["module"] à "autre" if self.external_data is None: self.external_data = {"module": "autre"} else: self.external_data["module"] = "autre" # Dans tous les cas une fois fait, assiduite.moduleimpl_id doit être None self.moduleimpl_id = None # Ici pas de vérification du force module car on l'a mis dans "external_data" return if moduleimpl_id != "": try: moduleimpl_id = int(moduleimpl_id) except ValueError as exc: raise ScoValueError("Module non reconnu") from exc moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) # ici moduleimpl est None si non spécifié # Vérification ModuleImpl not None (raise ScoValueError) if moduleimpl is None: self._check_force_module() # Ici uniquement si on est autorisé à ne pas avoir de module self.moduleimpl_id = None return # Vérification Inscription ModuleImpl (raise ScoValueError) if moduleimpl.est_inscrit(self.etudiant): self.moduleimpl_id = moduleimpl.id else: raise ScoValueError("L'étudiant n'est pas inscrit au module") 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: # route sans département set_sco_dept(self.etudiant.departement.acronym) obj_dict: dict = self.to_dict() # Suppression de l'objet et LOG log(f"delete_assidutite: {self.etudiant.id} {self}") Scolog.logdb( method="delete_assiduite", etudid=self.etudiant.id, msg=f"Assiduité: {self}", ) db.session.delete(self) db.session.commit() # Invalidation du cache scass.simple_invalidate_cache(obj_dict) def get_formsemestre(self) -> FormSemestre: """Le formsemestre associé. Attention: en cas d'inscription multiple prend arbitrairement l'un des semestres. A utiliser avec précaution ! """ return get_formsemestre_from_data(self.to_dict()) def get_module(self, traduire: bool = False) -> Module | str: """ Retourne le module associé à l'assiduité Si traduire est vrai, retourne le titre du module précédé du code Sinon retourne l'objet Module ou None """ if self.moduleimpl_id is not None: modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id) mod: Module = Module.query.get(modimpl.module_id) if traduire: return f"{mod.code} {mod.titre}" return mod if self.external_data is not None and "module" in self.external_data: return ( "Autre module (pas dans la liste)" if self.external_data["module"] == "Autre" else self.external_data["module"] ) return "Module non spécifié" if traduire else None def get_moduleimpl_id(self) -> int | str | None: """ Retourne le ModuleImpl associé à l'assiduité """ if self.moduleimpl_id is not None: return self.moduleimpl_id if self.external_data is not None and "module" in self.external_data: return self.external_data["module"] return None def get_saisie(self) -> str: """ retourne le texte "saisie le par " """ date: str = self.entry_date.strftime(scu.DATEATIME_FMT) utilisateur: str = "" if self.user is not None: self.user: User utilisateur = f"par {self.user.get_prenomnom()}" return f"saisie le {date} {utilisateur}" def _check_force_module(self): """Vérification si module forcé: Si le module est requis, raise ScoValueError sinon ne fait rien. """ # cherche le formsemestre affecté pour utiliser ses préférences formsemestre: FormSemestre = get_formsemestre_from_data( { "etudid": self.etudid, "date_debut": self.date_debut, "date_fin": self.date_fin, } ) formsemestre_id = formsemestre.id if formsemestre else None # si pas de formsemestre, utilisera les prefs globales du département dept_id = self.etudiant.dept_id force = is_assiduites_module_forced( formsemestre_id=formsemestre_id, dept_id=dept_id ) if force: raise ScoValueError("Module non renseigné") @classmethod def get_assiduite(cls, assiduite_id: int) -> "Assiduite": """Assiduité ou 404, cherche uniquement dans le département courant""" query = Assiduite.query.filter_by(id=assiduite_id) if g.scodoc_dept: query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) return query.first_or_404() class Justificatif(ScoDocModel): """ 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()) "date de création de l'élément: date de saisie" # pourrait devenir date de dépôt au secrétariat, si différente 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()) # Déclare la relation "joined" car on va très souvent vouloir récupérer # l'étudiant en même tant que le justificatif (perf.: évite nouvelle requete SQL) etudiant = db.relationship( "Identite", back_populates="justificatifs", lazy="joined" ) # En revanche, user est rarement accédé: user = db.relationship( "User", backref=db.backref( "justificatifs", lazy="select", order_by="Justificatif.entry_date" ), lazy="select", ) external_data = db.Column(db.JSON, nullable=True) @classmethod def get_justificatif(cls, justif_id: int) -> "Justificatif": """Justificatif ou 404, cherche uniquement dans le département courant""" query = Justificatif.query.filter_by(id=justif_id) if g.scodoc_dept: query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) return query.first_or_404() def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict: """L'objet en dictionnaire sérialisable. Si restrict, ne donne par la raison et les fichiers et external_data """ etat = self.etat user: User = self.user if self.user_id is not None else None if format_api: etat = EtatJustificatif.inverse().get(self.etat).name data = { "justif_id": self.justif_id, "etudid": self.etudid, "code_nip": self.etudiant.code_nip, "date_debut": self.date_debut, "date_fin": self.date_fin, "etat": etat, "raison": None if restrict else self.raison, "fichier": None if restrict else self.fichier, "entry_date": self.entry_date, "user_id": None if user is None else user.id, # l'uid "user_name": None if user is None else user.user_name, # le login "user_nom_complet": None if user is None else user.get_nomcomplet(), "external_data": None if restrict else self.external_data, } return data def __repr__(self) -> str: "chaine pour journaux et debug (lisible par humain français)" try: etat_str = EtatJustificatif(self.etat).name except ValueError: etat_str = "Invalide" return f"""Justificatif id={self.id} {etat_str} de { self.date_debut.strftime("%d/%m/%Y %Hh%M") } à { self.date_fin.strftime("%d/%m/%Y %Hh%M") }""" @classmethod def convert_dict_fields(cls, args: dict) -> dict: """Convert fields. Called by ScoDocModel's create_from_dict, edit and from_dict Raises ScoValueError si paramètres incorrects. """ if not isinstance(args["date_debut"], datetime) or not isinstance( args["date_fin"], datetime ): raise ScoValueError("type date incorrect") if args["date_fin"] <= args["date_debut"]: raise ScoValueError("dates incompatibles") if args["entry_date"] and not isinstance(args["entry_date"], datetime): raise ScoValueError("type entry_date incorrect") return args @classmethod 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, raison: str = None, entry_date: datetime = None, user_id: int = None, external_data: dict = None, ) -> "Justificatif": """Créer un nouveau justificatif pour l'étudiant. Raises ScoValueError si paramètres incorrects. """ nouv_justificatif = cls.create_from_dict(locals()) log(f"create_justificatif: etudid={etudiant.id} {nouv_justificatif}") Scolog.logdb( method="create_justificatif", etudid=etudiant.id, msg=f"justificatif: {nouv_justificatif}", ) db.session.commit() return nouv_justificatif 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 # Récupération de l'archive du justificatif archive_name: str = self.fichier if archive_name is not None: # Si elle existe : on essaye de la supprimer archiver: JustificatifArchiver = JustificatifArchiver() try: archiver.delete_justificatif(self.etudiant, archive_name) except ValueError: pass if g.scodoc_dept is None and self.etudiant.dept_id is not None: # route sans département set_sco_dept(self.etudiant.departement.acronym) # On invalide le cache scass.simple_invalidate_cache(self.to_dict()) # Suppression de l'objet et LOG log(f"delete_justificatif: {self.etudiant.id} {self}") Scolog.logdb( method="delete_justificatif", etudid=self.etudiant.id, msg=f"Justificatif: {self}", ) db.session.delete(self) db.session.commit() # On actualise les assiduités justifiées de l'étudiant concerné self.dejustifier_assiduites() def get_fichiers(self) -> tuple[list[str], int]: """Renvoie la liste des noms de fichiers justicatifs accessibles par l'utilisateur courant et le nombre total de fichiers. (ces fichiers sont dans l'archive associée) """ if self.fichier is None: return [], 0 archive_name: str = self.fichier archiver: JustificatifArchiver = JustificatifArchiver() filenames = archiver.list_justificatifs(archive_name, self.etudiant) accessible_filenames = [] # for filename in filenames: if int(filename[1]) == current_user.id or current_user.has_permission( Permission.AbsJustifView ): accessible_filenames.append(filename[0]) return accessible_filenames, len(filenames) def justifier_assiduites( self, ) -> list[int]: """Justifie les assiduités sur la période de validité du justificatif""" log(f"justifier_assiduites: {self}") assiduites_justifiees: list[int] = [] if self.etat != EtatJustificatif.VALIDE: return [] # On récupère les assiduités de l'étudiant sur la période donnée assiduites: Query = self.etudiant.assiduites.filter( Assiduite.date_debut >= self.date_debut, Assiduite.date_fin <= self.date_fin, Assiduite.etat != EtatAssiduite.PRESENT, ) # Pour chaque assiduité, on la justifie for assi in assiduites: assi.est_just = True assiduites_justifiees.append(assi.assiduite_id) db.session.add(assi) db.session.commit() return assiduites_justifiees def dejustifier_assiduites(self) -> list[int]: """ Déjustifie les assiduités sur la période du justificatif """ assiduites_dejustifiees: list[int] = [] # On récupère les assiduités de l'étudiant sur la période donnée assiduites: Query = self.etudiant.assiduites.filter( Assiduite.date_debut >= self.date_debut, Assiduite.date_fin <= self.date_fin, Assiduite.etat != EtatAssiduite.PRESENT, ) assi: Assiduite for assi in assiduites: # On récupère les justificatifs qui justifient l'assiduité `assi` assi_justifs: list[int] = get_justifs_from_date( self.etudiant.etudid, assi.date_debut, assi.date_fin, long=False, valid=True, ) # Si il n'y a pas d'autre justificatif valide, on déjustifie l'assiduité if len(assi_justifs) == 0 or ( len(assi_justifs) == 1 and assi_justifs[0] == self.justif_id ): assi.est_just = False assiduites_dejustifiees.append(assi.assiduite_id) db.session.add(assi) db.session.commit() return assiduites_dejustifiees def get_assiduites(self) -> Query: """ get_assiduites Récupère les assiduités qui sont concernées par le justificatif (Concernée ≠ Justifiée, mais qui sont sur la même période) Ne prends pas en compte les Présences Returns: Query: Les assiduités concernées """ assiduites_query = Assiduite.query.filter( Assiduite.etudid == self.etudid, Assiduite.date_debut >= self.date_debut, Assiduite.date_fin <= self.date_fin, Assiduite.etat != EtatAssiduite.PRESENT, ) return assiduites_query def is_period_conflicting( date_debut: datetime, date_fin: datetime, collection: Query, collection_cls: Assiduite | Justificatif, obj_id: int = -1, ) -> bool: """ Vérifie si une date n'entre pas en collision avec les justificatifs ou assiduites déjà présentes On peut donner un objet_id pour exclure un objet de la vérification (utile pour les modifications) """ # On s'assure que les dates soient avec TimeZone date_debut = localize_datetime(date_debut) date_fin = localize_datetime(date_fin) count: int = collection.filter( collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut, collection_cls.id != obj_id, ).count() return count > 0 def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]: """ get_assiduites_justif Récupération des justificatifs d'une assiduité Args: assiduite_id (int): l'identifiant de l'assiduité long (bool): Retourner des dictionnaires à la place des identifiants des justificatifs Returns: list[int | dict]: La liste des justificatifs (par défaut uniquement les identifiants, sinon les dict si long est vrai) """ assi: Assiduite = Assiduite.query.get_or_404(assiduite_id) return get_justifs_from_date(assi.etudid, assi.date_debut, assi.date_fin, long) def get_justifs_from_date( etudid: int, date_debut: datetime, date_fin: datetime, long: bool = False, valid: bool = False, ) -> list[int | dict]: """ get_justifs_from_date Récupération des justificatifs couvrant une période pour un étudiant donné Args: etudid (int): l'identifiant de l'étudiant date_debut (datetime): la date de début (datetime avec timezone) date_fin (datetime): la date de fin (datetime avec timezone) long (bool, optional): Définition de la sortie. Vrai pour avoir les dictionnaires des justificatifs. Faux pour avoir uniquement les identifiants Defaults to False. valid (bool, optional): Filtre pour n'avoir que les justificatifs valide. Si vrai : le retour ne contiendra que des justificatifs valides Sinon le retour contiendra tout type de justificatifs Defaults to False. Returns: list[int | dict]: La liste des justificatifs (par défaut uniquement les identifiants, sinon les dict si long est vrai) """ # On récupère les justificatifs d'un étudiant couvrant la période donnée justifs: Query = Justificatif.query.filter( Justificatif.etudid == etudid, Justificatif.date_debut <= date_debut, Justificatif.date_fin >= date_fin, ) # si valide est vrai alors on filtre pour n'avoir que les justificatifs valide if valid: justifs = justifs.filter(Justificatif.etat == EtatJustificatif.VALIDE) # On renvoie la liste des id des justificatifs si long est Faux, # sinon on renvoie les dicts des justificatifs if long: return [j.to_dict(True) for j in justifs] return [j.justif_id for j in justifs] def get_formsemestre_from_data(data: dict[str, datetime | int]) -> FormSemestre: """ get_formsemestre_from_data récupère un formsemestre en fonction des données passées Si l'étudiant est inscrit à plusieurs formsemestre, prend le premier. Args: data (dict[str, datetime | int]): Une représentation simplifiée d'une assiduité ou d'un justificatif data = { "etudid" : int, "date_debut": datetime (tz), "date_fin": datetime (tz), } Returns: FormSemestre: Le formsemestre trouvé ou None """ return ( FormSemestre.query.join( FormSemestreInscription, FormSemestre.id == FormSemestreInscription.formsemestre_id, ) .filter( data["date_debut"] <= FormSemestre.date_fin, data["date_fin"] >= FormSemestre.date_debut, FormSemestreInscription.etudid == data["etudid"], ) .first() ) def has_assiduites_disable_pref(formsemestre: FormSemestre) -> str | bool: """ Vérifie si le semestre possède la préférence "assiduites_disable" et renvoie le message d'erreur associé. La préférence est un text field. Il est considéré comme vide si : - la chaine de caractère est vide - si elle n'est composée que de caractères d'espacement (espace, tabulation, retour à la ligne) Si la chaine est vide, la fonction renvoie False """ # Si pas de formsemestre, on ne peut pas vérifier la préférence # On considère que la préférence n'est pas activée if formsemestre is None: return False pref: str = ( sco_preferences.get_preference("assiduites_disable", formsemestre.id) or "" ) pref = pref.strip() return pref if pref else False