from datetime import datetime from flask import url_for from flask_sqlalchemy.query import Pagination, Query from sqlalchemy import desc, literal, union 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.tables import table_builder as tb class ListeAssiJusti(tb.Table): """ Table listant les Assiduites et Justificatifs d'une collection d'étudiants L'affichage par défaut se fait par ordre de date de fin décroissante. """ NB_PAR_PAGE: int = 25 MAX_PAR_PAGE: int = 200 def __init__( self, table_data: "AssiJustifData", filtre: "AssiFiltre" = None, options: "AssiDisplayOptions" = None, **kwargs, ) -> None: """ __init__ Instancie un nouveau table de liste d'assiduités/justificaitifs Args: filtre (Filtre, optional): Filtrage des objets à afficher. Defaults to None. page (int, optional): numéro de page de la pagination. Defaults to 1. """ self.table_data: "AssiJustifData" = table_data # Gestion du filtre, par défaut un filtre vide self.filtre = filtre if filtre is not None else AssiFiltre() # Gestion des options, par défaut un objet Options vide self.options = options if options is not None else AssiDisplayOptions() self.total_page: int = None # les lignes du tableau self.rows: list["RowAssiJusti"] = [] # Instanciation de la classe parent super().__init__( row_class=RowAssiJusti, classes=["liste_assi", "gt_table", "gt_left"], **kwargs, with_foot_titles=False, ) self.add_assiduites() def add_assiduites(self): "Ajoute le contenu de la table, avec assiduités et justificatif réunis" # Générer les query assiduités et justificatifs assiduites_query_etudiants: Query = None justificatifs_query_etudiants: Query = None # Récupération du filtrage des objets -> 0 : tout, 1 : Assi, 2: Justi type_obj = self.filtre.type_obj() if type_obj in [0, 1]: assiduites_query_etudiants = self.table_data.assiduites_query # Non affichage des présences if not self.options.show_pres: assiduites_query_etudiants = assiduites_query_etudiants.filter( Assiduite.etat != EtatAssiduite.PRESENT ) # Non affichage des retards if not self.options.show_reta: assiduites_query_etudiants = assiduites_query_etudiants.filter( Assiduite.etat != EtatAssiduite.RETARD ) if type_obj in [0, 2]: justificatifs_query_etudiants = self.table_data.justificatifs_query # Combinaison des requêtes query_finale: Query = self.joindre( query_assiduite=assiduites_query_etudiants, query_justificatif=justificatifs_query_etudiants, ) # Paginer la requête pour ne pas envoyer trop d'informations au client pagination: Pagination = self.paginer(query_finale) self.total_pages: int = pagination.pages # Générer les lignes de la page for ligne in pagination.items: row: RowAssiJusti = self.row_class(self, ligne._asdict()) row.ajouter_colonnes() self.add_row(row) def paginer(self, query: Query) -> Pagination: """ Applique la pagination à une requête SQLAlchemy en fonction des paramètres de la classe. Cette méthode prend une requête SQLAlchemy et applique la pagination en utilisant les attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`. Args: query (Query): La requête SQLAlchemy à paginer. Il s'agit 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. Note: Cette méthode ne modifie pas la requête originale; elle renvoie plutôt un nouvel objet qui contient les résultats paginés. """ return query.paginate( page=self.options.page, per_page=self.options.nb_ligne_page, error_out=False ) def joindre(self, query_assiduite: Query = None, query_justificatif: Query = None): """ 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. Les colonnes sélectionnées sont: - 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) - 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 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. 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. Returns: sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour obtenir les résultats. Raises: ValueError: Si aucune requête n'est fournie (les deux paramètres sont None). """ queries = [] # Définir les colonnes pour la requête d'assiduité if query_assiduite: assiduites_entities: list = [ Assiduite.assiduite_id.label("obj_id"), Assiduite.etudid.label("etudid"), Assiduite.entry_date.label("entry_date"), Assiduite.date_debut.label("date_debut"), Assiduite.date_fin.label("date_fin"), Assiduite.etat.label("etat"), literal("assiduite").label("type"), Assiduite.est_just.label("est_just"), Assiduite.user_id.label("user_id"), ] if self.options.show_desc: assiduites_entities.append(Assiduite.description.label("description")) query_assiduite = query_assiduite.with_entities(*assiduites_entities) queries.append(query_assiduite) # Définir les colonnes pour la requête de justificatif if query_justificatif: justificatifs_entities: list = [ Justificatif.justif_id.label("obj_id"), Justificatif.etudid.label("etudid"), Justificatif.entry_date.label("entry_date"), Justificatif.date_debut.label("date_debut"), Justificatif.date_fin.label("date_fin"), Justificatif.etat.label("etat"), literal("justificatif").label("type"), # On doit avoir les mêmes colonnes sur les deux requêtes, # donc on la met en nul car un justifcatif ne peut être justifié literal(None).label("est_just"), Justificatif.user_id.label("user_id"), ] if self.options.show_desc: justificatifs_entities.append(Justificatif.raison.label("description")) query_justificatif = query_justificatif.with_entities( *justificatifs_entities ) queries.append(query_justificatif) # S'assurer qu'au moins une requête est fournie if not queries: raise ValueError( "Au moins une query (assiduité ou justificatif) doit être fournie" ) # Combiner les requêtes avec une union query_combinee = union(*queries).alias("combinee") query_combinee = db.session.query(query_combinee).order_by(desc("date_debut")) return query_combinee class RowAssiJusti(tb.Row): "Ligne de table pour une assiduité" def __init__(self, table: ListeAssiJusti, ligne: dict): self.ligne: dict = ligne self.etud: Identite = Identite.get_etud(ligne["etudid"]) super().__init__( table=table, row_id=f'{ligne["etudid"]}_{ligne["type"]}_{ligne["obj_id"]}', ) def ajouter_colonnes(self, lien_redirection: str = None): "Ajoute colonnes actions, étudiant, type, dates..." # Ajout colonne actions if self.table.options.show_actions: self._actions() # Ajout de l'étudiant self.table: ListeAssiJusti if self.table.options.show_etu: self._etud(lien_redirection) # Type d'objet self._type() # Date de début multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date() # En excel, on export les "vraes dates". # En HTML, on écrit en français (on laisse les dates pour le tri) self.add_cell( "date_debut", "Date de début", self.ligne["date_debut"].strftime("%d/%m/%y") if multi_days else self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"), data={"order": self.ligne["date_debut"]}, raw_content=self.ligne["date_debut"], column_classes={"date", "date-debut"}, ) # Date de fin self.add_cell( "date_fin", "Date de fin", self.ligne["date_fin"].strftime("%d/%m/%y") if multi_days else self.ligne["date_fin"].strftime("à %H:%M"), raw_content=self.ligne["date_fin"], # Pour excel data={"order": self.ligne["date_fin"]}, column_classes={"date", "date-fin"}, ) # Ajout des colonnes optionnelles self._optionnelles() # Ajout de l'utilisateur ayant saisi l'objet self._utilisateur() # Date de saisie self.add_cell( "entry_date", "Saisie le", self.ligne["entry_date"].strftime("%d/%m/%y à %H:%M") if self.ligne["entry_date"] else "?", data={"order": self.ligne["entry_date"] or ""}, raw_content=self.ligne["entry_date"], classes=["small-font"], column_classes={"entry_date"}, ) def _type(self) -> None: obj_type: str = "" is_assiduite: bool = self.ligne["type"] == "assiduite" if is_assiduite: justifiee: str = "Justifiée" if self.ligne["est_just"] else "" self.classes.append("row-assiduite") self.classes.append(EtatAssiduite(self.ligne["etat"]).name.lower()) if self.ligne["est_just"]: self.classes.append("justifiee") etat: str = { EtatAssiduite.PRESENT: "Présence", EtatAssiduite.ABSENT: "Absence", EtatAssiduite.RETARD: "Retard", }.get(self.ligne["etat"]) obj_type = f"{etat} {justifiee}" else: self.classes.append("row-justificatif") self.classes.append(EtatJustificatif(self.ligne["etat"]).name.lower()) etat: str = { EtatJustificatif.VALIDE: "valide", EtatJustificatif.ATTENTE: "soumis", EtatJustificatif.MODIFIE: "modifié", EtatJustificatif.NON_VALIDE: "invalide", }.get(self.ligne["etat"]) obj_type = f"Justificatif {etat}" self.add_cell("obj_type", "Type", obj_type, classes=["assi-type"]) def _etud(self, lien_redirection) -> None: etud = self.etud self.table.group_titles.update( { "etud_codes": "Codes", "identite_detail": "", "identite_court": "", } ) # Ajout des informations de l'étudiant self.add_cell( "nom_disp", "Nom", etud.nom_disp(), "etudinfo", attrs={"id": str(etud.id)}, data={"order": etud.sort_key}, target=lien_redirection, target_attrs={"class": "discretelink"}, ) self.add_cell( "prenom", "Prénom", etud.prenom_str, "etudinfo", attrs={"id": str(etud.id)}, data={"order": etud.sort_key}, target=lien_redirection, target_attrs={"class": "discretelink"}, ) def _optionnelles(self) -> None: if self.table.options.show_desc: self.add_cell( "description", "Description", self.ligne["description"] if self.ligne["description"] else "", ) if self.table.options.show_module: if self.ligne["type"] == "assiduite": assi: Assiduite = Assiduite.query.get(self.ligne["obj_id"]) mod: str = assi.get_module(True) self.add_cell("module", "Module", mod, data={"order": mod}) else: self.add_cell("module", "Module", "", data={"order": ""}) def _utilisateur(self) -> None: utilisateur: User = ( User.query.get(self.ligne["user_id"]) if self.ligne["user_id"] else None ) self.add_cell( "user", "Saisie par", "Inconnu" if utilisateur is None else utilisateur.get_nomprenom(), classes=["small-font"], ) def _actions(self) -> None: url: str html: list[str] = [] # Détails url = url_for( "assiduites.tableau_assiduite_actions", type=self.ligne["type"], action="details", obj_id=self.ligne["obj_id"], scodoc_dept=g.scodoc_dept, ) html.append(f'ℹ️') # Modifier if self.ligne["type"] == "justificatif": url = url_for( "assiduites.edit_justificatif_etud", justif_id=self.ligne["obj_id"], scodoc_dept=g.scodoc_dept, ) else: url = url_for( "assiduites.tableau_assiduite_actions", type=self.ligne["type"], action="modifier", obj_id=self.ligne["obj_id"], scodoc_dept=g.scodoc_dept, ) html.append(f'📝') # Supprimer url = url_for( "assiduites.tableau_assiduite_actions", type=self.ligne["type"], action="supprimer", obj_id=self.ligne["obj_id"], scodoc_dept=g.scodoc_dept, ) html.append(f'') # utiliser url_for self.add_cell( "actions", "", " ".join(html), no_excel=True, column_classes={"actions"}, ) class AssiFiltre: """ Classe représentant le filtrage qui sera appliqué aux objets du Tableau `ListeAssiJusti` """ def __init__( self, type_obj: int = 0, entry_date: tuple[int, datetime] = None, date_debut: tuple[int, datetime] = None, date_fin: tuple[int, datetime] = None, ) -> None: """ __init__ Instancie un nouvel objet filtre. 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. """ self.filtres = {"type_obj": type_obj} if entry_date is not None: self.filtres["entry_date"]: tuple[int, datetime] = entry_date if date_debut is not None: self.filtres["date_debut"]: tuple[int, datetime] = date_debut if date_fin is not None: self.filtres["date_fin"]: tuple[int, datetime] = date_fin def filtrage(self, query: Query, obj_class: db.Model) -> Query: """ filtrage Filtre la query passée en paramètre et retourne l'objet filtré Args: query (Query): La query à filtrer Returns: Query: La query filtrée """ query_filtree: Query = query cle_filtre: str for cle_filtre, val_filtre in self.filtres.items(): if "date" in cle_filtre: type_filtrage: int date: datetime type_filtrage, date = val_filtre match (type_filtrage): # On garde uniquement les dates supérieures au filtre case 2: query_filtree = query_filtree.filter( getattr(obj_class, cle_filtre) > date ) # On garde uniquement les dates inférieures au filtre case 1: query_filtree = query_filtree.filter( getattr(obj_class, cle_filtre) < date ) # Par défaut on garde uniquement les dates égales au filtre case _: query_filtree = query_filtree.filter( getattr(obj_class, cle_filtre) == date ) if cle_filtre == "etats": etats: list[int | EtatJustificatif | EtatAssiduite] = val_filtre # On garde uniquement les objets ayant un état compris dans le filtre query_filtree = query_filtree.filter(obj_class.etat.in_(etats)) return query_filtree def type_obj(self) -> int: """ type_obj Renvoi le/les types d'objets à représenter (0:Tout, 1: Assi, 2:Justi) Returns: int: le/les types d'objets à afficher """ return self.filtres.get("type_obj", 0) class AssiDisplayOptions: "Options pour affichage tableau" def __init__( self, page: int = 1, nb_ligne_page: int = None, show_pres: str | bool = False, show_reta: str | bool = False, show_desc: str | bool = False, show_etu: str | bool = True, show_actions: str | bool = True, show_module: str | bool = False, ): self.page: int = page self.nb_ligne_page: int = nb_ligne_page if self.nb_ligne_page is not None: self.nb_ligne_page = min(nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE) self.show_pres = to_bool(show_pres) self.show_reta = to_bool(show_reta) self.show_desc = to_bool(show_desc) self.show_etu = to_bool(show_etu) self.show_actions = to_bool(show_actions) self.show_module = to_bool(show_module) def remplacer(self, **kwargs): "Positionne options booléennes selon arguments" for k, v in kwargs.items(): if k.startswith("show_"): setattr(self, k, to_bool(v)) elif k in ["page", "nb_ligne_page"]: setattr(self, k, int(v)) if k == "nb_ligne_page": self.nb_ligne_page = min( self.nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE ) class AssiJustifData: "Les assiduités et justificatifs" def __init__( self, assiduites_query: Query = None, justificatifs_query: Query = None ): self.assiduites_query: Query = assiduites_query self.justificatifs_query: Query = justificatifs_query @staticmethod def from_etudiants(*etudiants: Identite) -> "AssiJustifData": data = AssiJustifData() data.assiduites_query = Assiduite.query.filter( Assiduite.etudid.in_([e.etudid for e in etudiants]) ) data.justificatifs_query = Justificatif.query.filter( Justificatif.etudid.in_([e.etudid for e in etudiants]) ) return data def get(self) -> tuple[Query, Query]: return self.assiduites_query, self.justificatifs_query