diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index b398b83cbb..e90098e5ec 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -646,8 +646,8 @@ def justif_import(justif_id: int = None): return json_error(404, err.args[0]) -@bp.route("/justificatif//export/", methods=["POST"]) -@api_web_bp.route("/justificatif//export/", methods=["POST"]) +@bp.route("/justificatif//export/", methods=["GET", "POST"]) +@api_web_bp.route("/justificatif//export/", methods=["GET", "POST"]) @scodoc @login_required @permission_required(Permission.AbsChange) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index a89e89b4f6..937e11c43c 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -3,8 +3,8 @@ """ from datetime import datetime -from app import db, log -from app.models import ModuleImpl, Scolog, FormSemestre, FormSemestreInscription +from app import db, log, g +from app.models import ModuleImpl, Module, Scolog, FormSemestre, FormSemestreInscription from app.models.etudiants import Identite from app.auth.models import User from app.scodoc import sco_abs_notification @@ -204,6 +204,77 @@ class Assiduite(db.Model): sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut) return nouv_assiduite + def set_moduleimpl(self, moduleimpl_id: int | str) -> bool: + moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) + if moduleimpl is not None: + # Vérification de l'inscription de l'étudiant + if moduleimpl.est_inscrit(self.etudiant): + self.moduleimpl_id = moduleimpl.id + else: + raise ScoValueError("L'étudiant n'est pas inscrit au module") + elif isinstance(moduleimpl_id, str): + if self.external_data is None: + self.external_data = {"module": moduleimpl_id} + else: + self.external_data["module"] = moduleimpl_id + self.moduleimpl_id = None + else: + # Vérification si module forcé + formsemestre: FormSemestre = get_formsemestre_from_data( + { + "etudid": self.etudid, + "date_debut": self.date_debut, + "date_fin": self.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é") + return True + + def supprimer(self): + 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=f"delete_assiduite", + etudid=self.etudiant.id, + msg=f"Assiduité: {self}", + ) + db.session.delete(self) + # Invalidation du cache + scass.simple_invalidate_cache(obj_dict) + + def get_formsemestre(self) -> FormSemestre: + return get_formsemestre_from_data(self.to_dict()) + + def get_module(self, traduire: bool = False) -> int | str: + if self.moduleimpl_id is not None: + if traduire: + modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id) + mod: Module = Module.query.get(modimpl.module_id) + return f"{mod.code} {mod.titre}" + + elif self.external_data is not None and "module" in self.external_data: + return ( + "Tout module" + if self.external_data["module"] == "Autre" + else self.external_data["module"] + ) + + return "Non spécifié" if traduire else None + class Justificatif(db.Model): """ @@ -334,6 +405,39 @@ class Justificatif(db.Model): ) return nouv_justificatif + def supprimer(self): + 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=f"delete_justificatif", + etudid=self.etudiant.id, + msg=f"Justificatif: {self}", + ) + db.session.delete(self) + # On actualise les assiduités justifiées de l'étudiant concerné + compute_assiduites_justified( + self.etudid, + Justificatif.query.filter_by(etudid=self.etudid).all(), + True, + ) + def is_period_conflicting( date_debut: datetime, diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 35fb343ea5..35fbbeea16 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -205,6 +205,13 @@ class EtatAssiduite(int, BiDirectionalEnum): RETARD = 1 ABSENT = 2 + def version_lisible(self) -> str: + return { + EtatAssiduite.PRESENT: "Présence", + EtatAssiduite.ABSENT: "Absence", + EtatAssiduite.RETARD: "Retard", + }.get(self, "") + class EtatJustificatif(int, BiDirectionalEnum): """Code des états des justificatifs""" @@ -216,6 +223,14 @@ class EtatJustificatif(int, BiDirectionalEnum): ATTENTE = 2 MODIFIE = 3 + def version_lisible(self) -> str: + return { + EtatJustificatif.VALIDE: "valide", + EtatJustificatif.ATTENTE: "soumis", + EtatJustificatif.MODIFIE: "modifié", + EtatJustificatif.NON_VALIDE: "invalide", + }.get(self, "") + def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: """ diff --git a/app/static/js/date_utils.js b/app/static/js/date_utils.js index 05babeedec..2989674382 100644 --- a/app/static/js/date_utils.js +++ b/app/static/js/date_utils.js @@ -448,6 +448,13 @@ class ScoDocDateTimePicker extends HTMLElement { // Ajouter le style au shadow DOM shadow.appendChild(style); + + //Si une value est donnée + + let value = this.getAttribute("value"); + if (value != null) { + this.value = value; + } } static get observedAttributes() { @@ -474,7 +481,7 @@ class ScoDocDateTimePicker extends HTMLElement { } else { // Mettre à jour la valeur de l'input caché avant la soumission this.hiddenInput.value = this.isValid() - ? this.valueAsDate.toIsoUtcString() + ? this.valueAsDate.toFakeIso() : ""; } }); diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py new file mode 100644 index 0000000000..6a61de53cb --- /dev/null +++ b/app/tables/liste_assiduites.py @@ -0,0 +1,536 @@ +from app.tables import table_builder as tb +from app.models import Identite, Assiduite, Justificatif +from app.auth.models import User +from datetime import datetime +from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif +from flask_sqlalchemy.query import Query, Pagination +from sqlalchemy import union, literal, select, desc +from app import db, g +from flask import url_for +from app import log + + +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: "Data", + filtre: "Filtre" = None, + options: "Options" = 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: "Data" = table_data + # Gestion du filtre, par défaut un filtre vide + self.filtre = filtre if filtre is not None else Filtre() + + # Gestion des options, par défaut un objet Options vide + self.options = options if options is not None else Options() + + self.total_page: int = None + + # les lignes du tableau + self.rows: list["RowAssiJusti"] = [] + + # Instanciation de la classe parent + super().__init__( + row_class=RowAssiJusti, + classes=["gt_table", "gt_left"], + **kwargs, + with_foot_titles=False, + ) + + self.ajouter_lignes() + + def ajouter_lignes(self): + # 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, aucune assiduité ne sera incluse dans la requête combinée. Defaults to None. + query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy pour les justificatifs. + Si None, aucun justificatif ne sera inclus dans la requête combinée. Defaults to None. + + 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): + 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): + # 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 + self.add_cell( + "date_debut", + "Date de début", + self.ligne["date_debut"].strftime("%d/%m/%y à %H:%M"), + data={"order": self.ligne["date_debut"]}, + raw_content=self.ligne["date_debut"], + ) + # Date de fin + self.add_cell( + "date_fin", + "Date de fin", + self.ligne["date_fin"].strftime("%d/%m/%y à %H:%M"), + raw_content=self.ligne["date_fin"], + data={"order": self.ligne["date_fin"]}, + ) + + # Ajout des colonnes optionnelles + self._optionnelles() + + # Ajout colonne actions + if self.table.options.show_actions: + self._actions() + + # Ajout de l'utilisateur ayant saisie l'objet + self._utilisateur() + + # Date de saisie + self.add_cell( + "entry_date", + "Saisie le", + self.ligne["entry_date"].strftime("%d/%m/%y à %H:%M"), + data={"order": self.ligne["entry_date"]}, + raw_content=self.ligne["entry_date"], + classes=["small-font"], + ) + + def _type(self) -> None: + obj_type: str = "" + is_assiduite: bool = self.ligne["type"] == "assiduite" + if is_assiduite: + etat: str = { + EtatAssiduite.PRESENT: "Présence", + EtatAssiduite.ABSENT: "Absence", + EtatAssiduite.RETARD: "Retard", + }.get(self.ligne["etat"]) + + justifiee: str = "Justifiée" if self.ligne["est_just"] else "" + obj_type = f"{etat} {justifiee}" + else: + 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) + + 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"]) + + 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'ℹ️') # utiliser url_for + + # Modifier + 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'📝') # utiliser url_for + + # 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", "Actions", " ".join(html), no_excel=True) + + +class Filtre: + """ + 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érieur au filtre + case 2: + query_filtree = query_filtree.filter( + getattr(obj_class, cle_filtre) > date + ) + # On garde uniquement les dates inférieur 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 Options: + VRAI = ["on", "true", "t", "v", "vrai", True, 1] + + 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: bool = show_pres in Options.VRAI + self.show_reta: bool = show_reta in Options.VRAI + self.show_desc: bool = show_desc in Options.VRAI + self.show_etu: bool = show_etu in Options.VRAI + self.show_actions: bool = show_actions in Options.VRAI + self.show_module: bool = show_module in Options.VRAI + + def remplacer(self, **kwargs): + for k, v in kwargs.items(): + if k.startswith("show_"): + setattr(self, k, v in Options.VRAI) + 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 Data: + 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) -> "Data": + data = Data() + 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 diff --git a/app/tables/table_builder.py b/app/tables/table_builder.py index 1f74d7d997..aea76c0d5e 100644 --- a/app/tables/table_builder.py +++ b/app/tables/table_builder.py @@ -84,6 +84,8 @@ class Table(Element): self.row_by_id: dict[str, "Row"] = {} self.column_ids = [] "ordered list of columns ids" + self.raw_column_ids = [] + "ordered list of columns ids for excel" self.groups = [] "ordered list of column groups names" self.group_titles = {} @@ -360,6 +362,7 @@ class Row(Element): target_attrs: dict = None, target: str = None, column_classes: set[str] = None, + no_excel: bool = False, ) -> "Cell": """Create cell and add it to the row. group: groupe de colonnes @@ -380,10 +383,17 @@ class Row(Element): target=target, target_attrs=target_attrs, ) - return self.add_cell_instance(col_id, cell, column_group=group, title=title) + return self.add_cell_instance( + col_id, cell, column_group=group, title=title, no_excel=no_excel + ) def add_cell_instance( - self, col_id: str, cell: "Cell", column_group: str = None, title: str = None + self, + col_id: str, + cell: "Cell", + column_group: str = None, + title: str = None, + no_excel: bool = False, ) -> "Cell": """Add a cell to the row. Si title est None, il doit avoir été ajouté avec table.add_title(). @@ -392,6 +402,9 @@ class Row(Element): self.cells[col_id] = cell if col_id not in self.table.column_ids: self.table.column_ids.append(col_id) + if not no_excel: + self.table.raw_column_ids.append(col_id) + self.table.insert_group(column_group) if column_group is not None: self.table.column_group[col_id] = column_group @@ -422,7 +435,7 @@ class Row(Element): """row as a dict, with only cell contents""" return { col_id: self.cells.get(col_id, self.table.empty_cell).raw_content - for col_id in self.table.column_ids + for col_id in self.table.raw_column_ids } def to_excel(self, sheet, style=None) -> list: diff --git a/app/tables/visu_assiduites.py b/app/tables/visu_assiduites.py index 4ccbae8f4f..4a48728fef 100644 --- a/app/tables/visu_assiduites.py +++ b/app/tables/visu_assiduites.py @@ -17,7 +17,7 @@ from app.scodoc import sco_utils as scu class TableAssi(tb.Table): - """Table listant l'assiduité des étudiants + """Table listant les statistiques d'assiduité des étudiants L'id de la ligne est etuid, et le row stocke etud. """ diff --git a/app/templates/assiduites/pages/ajout_assiduites.j2 b/app/templates/assiduites/pages/ajout_assiduites.j2 index 311b24a55c..1513226e38 100644 --- a/app/templates/assiduites/pages/ajout_assiduites.j2 +++ b/app/templates/assiduites/pages/ajout_assiduites.j2 @@ -2,7 +2,6 @@ {% block pageContent %}

Ajouter une assiduité

- {% include "assiduites/widgets/tableau_base.j2" %} {% if saisie_eval %}

@@ -63,8 +62,7 @@
- - {% include "assiduites/widgets/tableau_assi.j2" %} + {{tableau | safe }}
@@ -141,7 +139,7 @@ let assiduite_id = null; createAssiduiteComplete(assiduite, etudid); - loadAll(); + updateTableau(); btn.disabled = true; setTimeout(() => { btn.disabled = false; @@ -208,7 +206,6 @@ {% endif %} window.addEventListener("load", () => { - loadAll(); document.getElementById('assi_journee').addEventListener('click', () => { dayOnly() }); dayOnly() diff --git a/app/templates/assiduites/pages/ajout_justificatif.j2 b/app/templates/assiduites/pages/ajout_justificatif.j2 index d74737146a..c295fc9ec8 100644 --- a/app/templates/assiduites/pages/ajout_justificatif.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif.j2 @@ -2,8 +2,6 @@ {% block pageContent %}

Justifier des absences ou retards

- {% include "assiduites/widgets/tableau_base.j2" %} -
@@ -58,28 +56,9 @@
- - {% include "assiduites/widgets/tableau_justi.j2" %} + {{tableau | safe }}
-
- -

Gestion des justificatifs

-

- Faites - clic droit sur une ligne du tableau pour afficher le menu - contextuel : -

    -
  • Détails : Affiche les détails du justificatif sélectionné
  • -
  • Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)
  • -
  • Supprimer : Permet de supprimer le justificatif (Action Irréversible)
  • -
-

- -

Cliquer sur l'icone d'entonoir afin de filtrer le tableau des justificatifs

- -
-
\ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau_actions/details.j2 b/app/templates/assiduites/widgets/tableau_actions/details.j2 new file mode 100644 index 0000000000..fe10ad2b60 --- /dev/null +++ b/app/templates/assiduites/widgets/tableau_actions/details.j2 @@ -0,0 +1,106 @@ +

Détails {{type}}

+ +
+
+ Étudiant.e concerné.e: {{objet.etud_nom}} +
+ +
+ Période concernée : {{objet.date_debut}} au {{objet.date_fin}} +
+ + {% if type == "Assiduité" %} +
+ Module concernée : {{objet.module}} +
+ {% else %} + {% endif %} + +
+ {% if type == "Justificatif" %} + État du justificatif : + {% else %} + État de l'assiduité : + {% endif %} + {{objet.etat}} + +
+ +
+ {% if type == "Justificatif" %} +
Raison:
+ {% if objet.raison != None %} +
{{objet.raison}}
+ {% else %} +
/div> + {% endif %} + {% else %} +
Description:
+ {% if objet.description != None %} +
{{objet.description}}
+ {% else %} +
+ {% endif %} + {% endif %} +
+
+ + {# Affichage des justificatifs si assiduité justifiée #} + {% if type == "Assiduité" and objet.etat != "Présence" %} +
+ Justifiée: + {% if objet.justification.est_just %} + Oui +
+ {% for justi in objet.justification.justificatifs %} + Justificatif du {{justi.date_debut}} au {{justi.date_fin}} + {% endfor %} +
+ {% else %} + Non + {% endif %} +
+ {% endif %} + + {# Affichage des assiduités justifiées si justificatif valide #} + {% if type == "Justificatif" and objet.etat == "Valide" %} +
+ Assiduités concernées: + {% if objet.justification.assiduites %} +
+ {% for assi in objet.justification.assiduites %} + Assiduité {{assi.etat}} du {{assi.date_debut}} au + {{assi.date_fin}} + {% endfor %} +
+ {% else %} + Aucune + {% endif %} +
+ {% endif %} + + {# Affichage des fichiers des justificatifs #} + {% if type == "Justificatif"%} +
+ Fichiers enregistrés: + {% if objet.justification.fichiers.total != 0 %} +
Total : {{objet.justification.fichiers.total}}
+
    + {% for filename in objet.justification.fichiers.filenames %} +
  • {{filename}} +
  • + {% endfor %} +
+ {% else %} + Aucun + {% endif %} +
+ {% endif %} + +
+ Saisie par {{objet.saisie_par}} le {{objet.entry_date}} +
\ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau_actions/modifier.j2 b/app/templates/assiduites/widgets/tableau_actions/modifier.j2 new file mode 100644 index 0000000000..d76e38dbad --- /dev/null +++ b/app/templates/assiduites/widgets/tableau_actions/modifier.j2 @@ -0,0 +1,107 @@ +

Modifier {{type}}

+ +
+ + + + {% if type == "Assiduité" %} + + État + + + Module + {{moduleimpl | safe}} + + Description + + + {% else %} + + + Date de début + + Date de fin + + + État + + + Raison + + + Fichiers + +
+ + {% if objet.justification.fichiers.total != 0 %} +
Total : {{objet.justification.fichiers.total}}
+
    + {% for filename in objet.justification.fichiers.filenames %} +
  • + + {{filename}} +
  • + {% endfor %} +
+ {% else %} + Aucun + {% endif %} +
+
+ + + + + {% endif %} +
+
+ +
+ + + \ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 9570efef55..6427e84a59 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -32,6 +32,7 @@ from flask import abort, url_for, redirect from flask_login import current_user from app import db + from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.decorators import ( @@ -47,6 +48,10 @@ from app.models import ( Departement, Evaluation, ) +from app.auth.models import User +from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified +import app.tables.liste_assiduites as liste_assi + from app.views import assiduites_bp as bp from app.views import ScoData @@ -65,6 +70,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 CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS @@ -260,13 +266,6 @@ def signal_assiduites_etud(): if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") - # Récupération de la date (par défaut la date du jour) - date = request.args.get("date", datetime.date.today().isoformat()) - heures: list[str] = [ - request.args.get("heure_deb", ""), - request.args.get("heure_fin", ""), - ] - # gestion évaluations (Appel à la page depuis les évaluations) saisie_eval: bool = request.args.get("saisie_eval") is not None @@ -299,21 +298,17 @@ def signal_assiduites_etud(): ], ) - # Gestion des horaires (journée, matin, soir) - - morning = ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00") - lunch = ScoDocSiteConfig.assi_get_rounded_time("assi_lunch_time", "13:00:00") - afternoon = ScoDocSiteConfig.assi_get_rounded_time( - "assi_afternoon_time", "18:00:00" + tableau = _preparer_tableau( + liste_assi.Data.from_etudiants( + etud, + ), + filename=f"assiduite-{etudid}", + afficher_etu=False, + filtre=liste_assi.Filtre(type_obj=1), + options=liste_assi.Options(show_module=True), ) - - # Gestion du selecteur de moduleimpl (pour le tableau différé) - select = f""" - - """ - + if not tableau[0]: + return tableau[1] # Génération de la page return HTMLBuilder( header, @@ -327,12 +322,12 @@ def signal_assiduites_etud(): ), assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), - saisie_eval=saisie_eval, date_deb=date_deb, date_fin=date_fin, redirect_url=redirect_url, moduleimpl_id=moduleimpl_id, + tableau=tableau[1], ), # render_template( # "assiduites/pages/signal_assiduites_etud.j2", @@ -378,7 +373,7 @@ def liste_assiduites_etud(): if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") - # Gestion d'une assiduité unique (redirigé depuis le calendrier) + # Gestion d'une assiduité unique (redirigé depuis le calendrier) TODO-Assiduites assiduite_id: int = request.args.get("assiduite_id", -1) # Préparation de la page @@ -394,18 +389,25 @@ def liste_assiduites_etud(): "css/assiduites.css", ], ) + tableau = _preparer_tableau( + liste_assi.Data.from_etudiants( + etud, + ), + filename=f"assiduites-justificatifs-{etudid}", + afficher_etu=False, + filtre=liste_assi.Filtre(type_obj=0), + options=liste_assi.Options(show_module=True), + ) + if not tableau[0]: + return tableau[1] # Peuplement du template jinja return HTMLBuilder( header, render_template( "assiduites/pages/liste_assiduites.j2", sco=ScoData(etud), - date=datetime.date.today().isoformat(), assi_id=assiduite_id, - assi_limit_annee=sco_preferences.get_preference( - "assi_limit_annee", - dept_id=g.scodoc_dept_id, - ), + tableau=tableau[1], ), ).build() @@ -502,6 +504,19 @@ def ajout_justificatif_etud(): ], ) + tableau = _preparer_tableau( + liste_assi.Data.from_etudiants( + etud, + ), + filename=f"justificatifs-{etudid}", + afficher_etu=False, + filtre=liste_assi.Filtre(type_obj=2), + options=liste_assi.Options(show_module=False, show_desc=True), + afficher_options=False, + ) + if not tableau[0]: + return tableau[1] + # Peuplement du template jinja return HTMLBuilder( header, @@ -514,6 +529,7 @@ def ajout_justificatif_etud(): ), assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), + tableau=tableau[1], ), ).build() @@ -1045,6 +1061,318 @@ def visu_assi_group(): ) +def _preparer_tableau( + data: liste_assi.Data, + filename: str = "tableau-assiduites", + afficher_etu: bool = True, + filtre: liste_assi.Filtre = None, + options: liste_assi.Options = None, + afficher_options: bool = True, +) -> tuple[bool, "Response"]: + """ + _preparer_tableau prépare un tableau d'assiduités / justificatifs + + Cette fontion récupère dans la requête les arguments : + + valeurs possibles des booléens vrais ["on", "true", "t", "v", "vrai", True, 1] + toute autre valeur est considérée comme fausse. + + show_pres : bool -> Affiche les présences, par défaut False + show_reta : bool -> Affiche les retard, par défaut False + show_desc : bool -> Affiche les descriptions, par défaut False + + + + Returns: + tuple[bool | "Reponse" ]: + - bool : Vrai si la réponse est du Text/HTML + - Reponse : du Text/HTML ou Une Reponse (téléchargement fichier) + """ + + show_pres: bool | str = request.args.get("show_pres", False) + show_reta: bool | str = request.args.get("show_reta", False) + show_desc: bool | str = request.args.get("show_desc", False) + + nb_ligne_page: int = request.args.get("nb_ligne_page") + # Vérification de nb_ligne_page + try: + nb_ligne_page: int = int(nb_ligne_page) + except (ValueError, TypeError): + nb_ligne_page = liste_assi.ListeAssiJusti.NB_PAR_PAGE + + page_number: int = request.args.get("n_page", 1) + # Vérification de page_number + try: + page_number: int = int(page_number) + except (ValueError, TypeError): + page_number = 1 + + fmt = request.args.get("fmt", "html") + + if options is None: + options: liste_assi.Options = liste_assi.Options() + + options.remplacer( + page=page_number, + nb_ligne_page=nb_ligne_page, + show_pres=show_pres, + show_reta=show_reta, + show_desc=show_desc, + show_etu=afficher_etu, + ) + + table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( + table_data=data, + options=options, + filtre=filtre, + ) + + if fmt.startswith("xls"): + return False, scu.send_file( + table.excel(), + filename=filename, + mime=scu.XLSX_MIMETYPE, + suffix=scu.XLSX_SUFFIX, + ) + + return True, render_template( + "assiduites/widgets/tableau.j2", + tableau=table.html(), + total_pages=table.total_pages, + options=options, + afficher_options=afficher_options, + ) + + +@bp.route("/TableauAssiduiteActions", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.AbsChange) +def tableau_assiduite_actions(): + obj_type: str = request.args.get("type", "assiduite") + action: str = request.args.get("action", "details") + obj_id: str = int(request.args.get("obj_id", -1)) + + objet: Assiduite | Justificatif + + if obj_type == "assiduite": + objet: Assiduite = Assiduite.query.get_or_404(obj_id) + else: + objet: Justificatif = Justificatif.query.get_or_404(obj_id) + + if action == "supprimer": + objet.supprimer() + if obj_type == "assiduite": + flash("L'assiduité a bien été supprimée") + else: + flash("Le justificatif a bien été supprimé") + + return redirect(request.referrer) + + if request.method == "GET": + module = "" + + if obj_type == "assiduite": + formsemestre = objet.get_formsemestre() + if objet.moduleimpl_id is not None: + module = objet.moduleimpl_id + elif objet.external_data is not None: + module = objet.external_data.get("module", "") + module = module.lower() if isinstance(module, str) else module + module = _module_selector(formsemestre, module) + + return render_template( + "assiduites/pages/tableau_actions.j2", + sco=ScoData(etud=objet.etudiant), + type="Justificatif" if obj_type == "justificatif" else "Assiduité", + action=action, + objet=_preparer_objet(obj_type, objet), + obj_id=obj_id, + moduleimpl=module, + ) + # Cas des POSTS + if obj_type == "assiduite": + try: + _action_modifier_assiduite(objet) + except ScoValueError as error: + raise ScoValueError(error.args[0], request.referrer) from error + flash("L'assiduité a bien été modifiée.") + else: + try: + _action_modifier_justificatif(objet) + except ScoValueError as error: + raise ScoValueError(error.args[0], request.referrer) from error + flash("Le justificatif a bien été modifié.") + return redirect(request.form["table_url"]) + + +def _action_modifier_assiduite(assi: Assiduite): + form = request.form + + # Gestion de l'état + etat = scu.EtatAssiduite.get(form["etat"]) + if etat is not None: + assi.etat = etat + if etat == scu.EtatAssiduite.PRESENT: + assi.est_just = False + else: + assi.est_just = len(get_assiduites_justif(assi.assiduite_id, False)) > 0 + + # Gestion de la description + assi.description = form["description"] + + module: str = form["moduleimpl_select"] + + if module == "": + module = None + else: + try: + module = int(module) + except ValueError: + pass + + assi.set_moduleimpl(module) + + db.session.add(assi) + db.session.commit() + scass.simple_invalidate_cache(assi.to_dict(True), assi.etudid) + + +def _action_modifier_justificatif(justi: Justificatif): + form = request.form + + # Gestion des Dates + + date_debut: datetime = scu.is_iso_formated(form["date_debut"], True) + date_fin: datetime = scu.is_iso_formated(form["date_fin"], True) + if date_debut is None or date_fin is None or date_fin < date_debut: + raise ScoValueError("Dates invalides", request.referrer) + justi.date_debut = date_debut + justi.date_fin = date_fin + + # Gestion de l'état + etat = scu.EtatJustificatif.get(form["etat"]) + if etat is not None: + justi.etat = etat + else: + raise ScoValueError("État invalide", request.referrer) + + # Gestion de la raison + justi.raison = form["raison"] + + # Gestion des fichiers + files = request.files.getlist("justi_fich") + if len(files) != 0: + files = request.files.values() + + archive_name: str = justi.fichier + # Utilisation de l'archiver de justificatifs + archiver: JustificatifArchiver = JustificatifArchiver() + + for fich in files: + archive_name, _ = archiver.save_justificatif( + justi.etudiant, + filename=fich.filename, + data=fich.stream.read(), + archive_name=archive_name, + user_id=current_user.id, + ) + + justi.fichier = archive_name + + db.session.add(justi) + db.session.commit() + scass.compute_assiduites_justified(justi.etudid, reset=True) + scass.simple_invalidate_cache(justi.to_dict(True), justi.etudid) + + +def _preparer_objet( + obj_type: str, objet: Assiduite | Justificatif, sans_gros_objet: bool = False +) -> dict: + # Préparation d'un objet pour simplifier l'affichage jinja + objet_prepare: dict = objet.to_dict() + if obj_type == "assiduite": + objet_prepare["etat"] = ( + scu.EtatAssiduite(objet.etat).version_lisible().capitalize() + ) + objet_prepare["real_etat"] = scu.EtatAssiduite(objet.etat).name.lower() + objet_prepare["description"] = ( + "" if objet.description is None else objet.description + ) + objet_prepare["description"] = objet_prepare["description"].strip() + + # Gestion du module + objet_prepare["module"] = objet.get_module(True) + + # Gestion justification + + if not objet.est_just: + objet_prepare["justification"] = {"est_just": False} + else: + objet_prepare["justification"] = {"est_just": True, "justificatifs": []} + + if not sans_gros_objet: + justificatifs: list[int] = get_assiduites_justif( + objet.assiduite_id, False + ) + for justi_id in justificatifs: + justi: Justificatif = Justificatif.query.get(justi_id) + objet_prepare["justification"]["justificatifs"].append( + _preparer_objet("justificatif", justi, sans_gros_objet=True) + ) + + else: + objet_prepare["etat"] = ( + scu.EtatJustificatif(objet.etat).version_lisible().capitalize() + ) + objet_prepare["real_etat"] = scu.EtatJustificatif(objet.etat).name.lower() + objet_prepare["raison"] = "" if objet.raison is None else objet.raison + objet_prepare["raison"] = objet_prepare["raison"].strip() + + objet_prepare["justification"] = {"assiduites": [], "fichiers": {}} + if not sans_gros_objet: + assiduites: list[int] = scass.justifies(objet) + for assi_id in assiduites: + assi: Assiduite = Assiduite.query.get(assi_id) + objet_prepare["justification"]["assiduites"].append( + _preparer_objet("assiduite", assi, sans_gros_objet=True) + ) + + # Récupération de l'archive avec l'archiver + archive_name: str = objet.fichier + filenames: list[str] = [] + archiver: JustificatifArchiver = JustificatifArchiver() + if archive_name is not None: + filenames = archiver.list_justificatifs(archive_name, objet.etudiant) + objet_prepare["justification"]["fichiers"] = { + "total": len(filenames), + "filenames": [], + } + for filename in filenames: + if int(filename[1]) == current_user.id or current_user.has_permission( + Permission.AbsJustifView + ): + objet_prepare["justification"]["fichiers"]["filenames"].append( + filename[0] + ) + + objet_prepare["date_fin"] = objet.date_fin.strftime("%d/%m/%y à %H:%M") + objet_prepare["real_date_fin"] = objet.date_fin.isoformat() + objet_prepare["date_debut"] = objet.date_debut.strftime("%d/%m/%y à %H:%M") + objet_prepare["real_date_debut"] = objet.date_debut.isoformat() + + objet_prepare["entry_date"] = objet.entry_date.strftime("%d/%m/%y à %H:%M") + + objet_prepare["etud_nom"] = objet.etudiant.nomprenom + + if objet.user_id != None: + user: User = User.query.get(objet.user_id) + objet_prepare["saisie_par"] = user.get_nomprenom() + else: + objet_prepare["saisie_par"] = "Inconnu" + + return objet_prepare + + @bp.route("/SignalAssiduiteDifferee") @scodoc @permission_required(Permission.AbsChange) @@ -1325,10 +1653,10 @@ def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str: ) -@bp.route("/test", methods=["GET", "POST"]) +@bp.route("/testDate", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) -def test(): +def testDateutils(): """XXX fonction de test a retirer""" if request.method == "POST": print("test date_utils : ", request.form) @@ -1480,12 +1808,6 @@ def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> s # prévoie la sélection par défaut d'un moduleimpl s'il a été passé en paramètre selected = "" if moduleimpl_id is not None else "selected" - # Vérification que le moduleimpl_id passé en paramètre est bien un entier - try: - moduleimpl_id = int(moduleimpl_id) - except (ValueError, TypeError): - moduleimpl_id = None - modules: list[dict[str, str | int]] = [] # Récupération de l'id et d'un nom lisible pour chaque moduleimpl for modimpl in modimpls_list: