Update opolka/ScoDoc from ScoDoc/ScoDoc #2

Merged
opolka merged 1272 commits from ScoDoc/ScoDoc:master into master 2024-05-27 09:11:04 +02:00
19 changed files with 1380 additions and 175 deletions
Showing only changes of commit 7cda427cac - Show all commits

View File

@ -646,8 +646,8 @@ def justif_import(justif_id: int = None):
return json_error(404, err.args[0]) return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"]) @bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"]) @api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["GET", "POST"])
@scodoc @scodoc
@login_required @login_required
@permission_required(Permission.AbsChange) @permission_required(Permission.AbsChange)

View File

@ -3,8 +3,8 @@
""" """
from datetime import datetime from datetime import datetime
from app import db, log from app import db, log, g
from app.models import ModuleImpl, Scolog, FormSemestre, FormSemestreInscription from app.models import ModuleImpl, Module, Scolog, FormSemestre, FormSemestreInscription
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.auth.models import User from app.auth.models import User
from app.scodoc import sco_abs_notification 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) sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut)
return nouv_assiduite 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): class Justificatif(db.Model):
""" """
@ -334,6 +405,39 @@ class Justificatif(db.Model):
) )
return nouv_justificatif 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( def is_period_conflicting(
date_debut: datetime, date_debut: datetime,

View File

@ -205,6 +205,13 @@ class EtatAssiduite(int, BiDirectionalEnum):
RETARD = 1 RETARD = 1
ABSENT = 2 ABSENT = 2
def version_lisible(self) -> str:
return {
EtatAssiduite.PRESENT: "Présence",
EtatAssiduite.ABSENT: "Absence",
EtatAssiduite.RETARD: "Retard",
}.get(self, "")
class EtatJustificatif(int, BiDirectionalEnum): class EtatJustificatif(int, BiDirectionalEnum):
"""Code des états des justificatifs""" """Code des états des justificatifs"""
@ -216,6 +223,14 @@ class EtatJustificatif(int, BiDirectionalEnum):
ATTENTE = 2 ATTENTE = 2
MODIFIE = 3 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: def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None:
""" """

View File

@ -448,6 +448,13 @@ class ScoDocDateTimePicker extends HTMLElement {
// Ajouter le style au shadow DOM // Ajouter le style au shadow DOM
shadow.appendChild(style); shadow.appendChild(style);
//Si une value est donnée
let value = this.getAttribute("value");
if (value != null) {
this.value = value;
}
} }
static get observedAttributes() { static get observedAttributes() {
@ -474,7 +481,7 @@ class ScoDocDateTimePicker extends HTMLElement {
} else { } else {
// Mettre à jour la valeur de l'input caché avant la soumission // Mettre à jour la valeur de l'input caché avant la soumission
this.hiddenInput.value = this.isValid() this.hiddenInput.value = this.isValid()
? this.valueAsDate.toIsoUtcString() ? this.valueAsDate.toFakeIso()
: ""; : "";
} }
}); });

View File

@ -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'<a title="Détails" href="{url}"></a>') # 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'<a title="Modifier" href="{url}">📝</a>') # 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'<a title="Supprimer" href="{url}">❌</a>') # utiliser url_for
self.add_cell("actions", "Actions", "&ensp;".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

View File

@ -84,6 +84,8 @@ class Table(Element):
self.row_by_id: dict[str, "Row"] = {} self.row_by_id: dict[str, "Row"] = {}
self.column_ids = [] self.column_ids = []
"ordered list of columns ids" "ordered list of columns ids"
self.raw_column_ids = []
"ordered list of columns ids for excel"
self.groups = [] self.groups = []
"ordered list of column groups names" "ordered list of column groups names"
self.group_titles = {} self.group_titles = {}
@ -360,6 +362,7 @@ class Row(Element):
target_attrs: dict = None, target_attrs: dict = None,
target: str = None, target: str = None,
column_classes: set[str] = None, column_classes: set[str] = None,
no_excel: bool = False,
) -> "Cell": ) -> "Cell":
"""Create cell and add it to the row. """Create cell and add it to the row.
group: groupe de colonnes group: groupe de colonnes
@ -380,10 +383,17 @@ class Row(Element):
target=target, target=target,
target_attrs=target_attrs, 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( 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": ) -> "Cell":
"""Add a cell to the row. """Add a cell to the row.
Si title est None, il doit avoir été ajouté avec table.add_title(). 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 self.cells[col_id] = cell
if col_id not in self.table.column_ids: if col_id not in self.table.column_ids:
self.table.column_ids.append(col_id) 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) self.table.insert_group(column_group)
if column_group is not None: if column_group is not None:
self.table.column_group[col_id] = column_group self.table.column_group[col_id] = column_group
@ -422,7 +435,7 @@ class Row(Element):
"""row as a dict, with only cell contents""" """row as a dict, with only cell contents"""
return { return {
col_id: self.cells.get(col_id, self.table.empty_cell).raw_content 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: def to_excel(self, sheet, style=None) -> list:

View File

@ -17,7 +17,7 @@ from app.scodoc import sco_utils as scu
class TableAssi(tb.Table): 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. L'id de la ligne est etuid, et le row stocke etud.
""" """

View File

@ -2,7 +2,6 @@
{% block pageContent %} {% block pageContent %}
<div class="pageContent"> <div class="pageContent">
<h3>Ajouter une assiduité</h3> <h3>Ajouter une assiduité</h3>
{% include "assiduites/widgets/tableau_base.j2" %}
{% if saisie_eval %} {% if saisie_eval %}
<div id="saisie_eval"> <div id="saisie_eval">
<br> <br>
@ -63,8 +62,7 @@
</section> </section>
<section class="liste"> <section class="liste">
<a class="icon filter" onclick="filterAssi()"></a> {{tableau | safe }}
{% include "assiduites/widgets/tableau_assi.j2" %}
</section> </section>
</div> </div>
@ -141,7 +139,7 @@
let assiduite_id = null; let assiduite_id = null;
createAssiduiteComplete(assiduite, etudid); createAssiduiteComplete(assiduite, etudid);
loadAll(); updateTableau();
btn.disabled = true; btn.disabled = true;
setTimeout(() => { setTimeout(() => {
btn.disabled = false; btn.disabled = false;
@ -208,7 +206,6 @@
{% endif %} {% endif %}
window.addEventListener("load", () => { window.addEventListener("load", () => {
loadAll();
document.getElementById('assi_journee').addEventListener('click', () => { dayOnly() }); document.getElementById('assi_journee').addEventListener('click', () => { dayOnly() });
dayOnly() dayOnly()

View File

@ -2,8 +2,6 @@
{% block pageContent %} {% block pageContent %}
<div class="pageContent"> <div class="pageContent">
<h3>Justifier des absences ou retards</h3> <h3>Justifier des absences ou retards</h3>
{% include "assiduites/widgets/tableau_base.j2" %}
<section class="justi-form page"> <section class="justi-form page">
@ -58,28 +56,9 @@
</section> </section>
<section class="liste"> <section class="liste">
<a class="icon filter" onclick="filterJusti()"></a> {{tableau | safe }}
{% include "assiduites/widgets/tableau_justi.j2" %}
</section> </section>
<div class="legende">
<h3>Gestion des justificatifs</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
<ul>
<li>Détails : Affiche les détails du justificatif sélectionné</li>
<li>Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)</li>
<li>Supprimer : Permet de supprimer le justificatif (Action Irréversible)</li>
</ul>
</p>
<p>Cliquer sur l'icone d'entonoir afin de filtrer le tableau des justificatifs</p>
</div>
</div> </div>
<style> <style>
@ -167,17 +146,15 @@
processData: false, processData: false,
success: () => { success: () => {
pushToast(generateToast(document.createTextNode(`Importation du fichier : ${f.name} finie`))); pushToast(generateToast(document.createTextNode(`Importation du fichier : ${f.name} finie`)));
loadAll();
}, },
} }
) )
) )
}); });
if (in_files.files.length == 0) { $.when(...requests).done(() => {
loadAll(); location.reload();
} })
} }
function validerFormulaire(btn) { function validerFormulaire(btn) {
@ -258,7 +235,6 @@
const assi_evening = '{{assi_evening}}'; const assi_evening = '{{assi_evening}}';
window.onload = () => { window.onload = () => {
loadAll();
document.getElementById('justi_journee').addEventListener('click', () => { dayOnly() }); document.getElementById('justi_journee').addEventListener('click', () => { dayOnly() });
dayOnly() dayOnly()

View File

@ -2,95 +2,6 @@
<div class="pageContent"> <div class="pageContent">
<h2>Liste de l'assiduité et des justificatifs de <span class="rouge">{{sco.etud.nomprenom}}</span></h2> <h2>Liste de l'assiduité et des justificatifs de <span class="rouge">{{sco.etud.nomprenom}}</span></h2>
{% include "assiduites/widgets/tableau_base.j2" %} {{tableau | safe }}
<h3>Assiduité :</h3>
<span class="iconline">
<a class="icon filter" onclick="filterAssi()"></a>
<a class="icon download" onclick="downloadAssi()"></a>
</span>
{% include "assiduites/widgets/tableau_assi.j2" %}
<h3>Justificatifs :</h3>
<span class="iconline">
<a class="icon filter" onclick="filterJusti()"></a>
<a class="icon download" onclick="downloadJusti()"></a>
</span>
{% include "assiduites/widgets/tableau_justi.j2" %}
<ul id="contextMenu" class="context-menu">
<li id="detailOption">Detail</li>
<li id="editOption">Editer</li>
<li id="deleteOption">Supprimer</li>
</ul>
<div class="legende">
<h3>Gestion des justificatifs</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
</p>
<ul>
<li>Détails : Affiche les détails du justificatif sélectionné</li>
<li>Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)</li>
<li>Supprimer : Permet de supprimer le justificatif (Action Irréversible)</li>
</ul>
<p>Vous pouvez filtrer le tableau en cliquant sur l'icone d'entonnoir sous le titre du tableau.</p>
<h3>Gestion de l'assiduité</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
</p>
<ul>
<li>Détails : affiche les détails de l'assiduité sélectionnée</li>
<li>Éditer : modifier l'élément (module, état)</li>
<li>Supprimer : supprimer l'élément (action irréversible)</li>
</ul>
<p>Vous pouvez filtrer le tableau en cliquant sur l'icone d'entonnoir sous le titre du tableau.</p>
</div>
</div> </div>
{% endblock app_content %} {% endblock app_content %}
<script>
const etudid = {{ sco.etud.id }}
const assiduite_unique_id = {{ assi_id }};
const assi_limit_annee = "{{ assi_limit_annee }}" == "True" ? true : false;
function wayForFilter() {
if (typeof assiduites[etudid] !== "undefined") {
console.log("Done")
let assiduite = assiduites[etudid].filter((a) => { return a.assiduite_id == assiduite_unique_id });
if (assiduite) {
assiduite = assiduite[0]
filterAssiduites["filters"] = {
"obj_id": [
assiduite.assiduite_id,
]
}
const obj_ids = assiduite.justificatifs ? assiduite.justificatifs.map((j) => { return j.justif_id }) : []
filterJustificatifs["filters"] = {
"obj_id": obj_ids
}
loadAll();
}
} else {
setTimeout(wayForFilter, 250)
}
}
window.onload = () => {
loadAll();
if (assiduite_unique_id != -1) {
wayForFilter()
}
}
</script>

View File

@ -0,0 +1,27 @@
{% extends "sco_page.j2" %}
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script>
<script src="{{scu.STATIC_DIR}}/js/date_utils.js"></script>
{% endblock %}
{% block app_content %}
{% if action == "modifier" %}
{% include "assiduites/widgets/tableau_actions/modifier.j2" %}
{% else%}
{% include "assiduites/widgets/tableau_actions/details.j2" %}
{% endif %}
<br>
<hr>
<br>
<a href="" id="lien-retour">Retour</a>
<script>
window.addEventListener('load', () => {
document.getElementById("lien-retour").href = document.referrer;
})
</script>
{% endblock %}

View File

@ -3,7 +3,6 @@
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script> <script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script>
<script src="{{scu.STATIC_DIR}}/js/date_utils.js"></script>
{% endblock %} {% endblock %}
{% block app_content %} {% block app_content %}
@ -22,7 +21,7 @@
{{tableau | safe}} {{tableau | safe}}
<div class="" help"> <div class="help">
Les comptes sont exprimés en {{ assi_metric | lower}}s. Les comptes sont exprimés en {{ assi_metric | lower}}s.
</div> </div>

View File

@ -119,11 +119,17 @@
} }
{% if moduleid %}
const moduleimpl_dynamic_selector_id = "{{moduleid}}"
{% else %}
const moduleimpl_dynamic_selector_id = "moduleimpl_select"
{% endif %}
window.addEventListener("load", () => { window.addEventListener("load", () => {
document.getElementById('moduleimpl_select').addEventListener('change', (el) => { document.getElementById(moduleimpl_dynamic_selector_id).addEventListener('change', (el) => {
const assi = getCurrentAssiduite(etudid); const assi = getCurrentAssiduite(etudid);
if (assi) { if (assi) {
editAssiduite(assi.assiduite_id, assi.etat, [assi]); editAssiduite(assi.assiduite_id, assi.etat, [assi]);

View File

@ -1,6 +1,8 @@
<select name="moduleimpl_select" id="moduleimpl_select"> <select name="moduleimpl_select" id="moduleimpl_select">
{% with moduleimpl_id=moduleimpl_id %}
{% include "assiduites/widgets/simplemoduleimpl_select.j2" %} {% include "assiduites/widgets/simplemoduleimpl_select.j2" %}
{% endwith %}
{% for mod in modules %} {% for mod in modules %}
{% if mod.moduleimpl_id == moduleimpl_id %} {% if mod.moduleimpl_id == moduleimpl_id %}

View File

@ -1,6 +1,10 @@
{% if scu.is_assiduites_module_forced(request.args.get('formsemestre_id', None))%} {% if scu.is_assiduites_module_forced(request.args.get('formsemestre_id', None))%}
<option value="" selected disabled> Saisir Module</option> <option value="" disabled> Saisir Module</option>
{% else %} {% else %}
<option value="" selected> Non spécifié </option> <option value=""> Non spécifié </option>
{% endif %} {% endif %}
{% if moduleimpl_id == "autre" %}
<option value="autre" selected> Tout module </option>
{% else %}
<option value="autre"> Tout module </option> <option value="autre"> Tout module </option>
{% endif %}

View File

@ -0,0 +1,73 @@
<hr>
<div>
<h3>Options</h3>
<div id="options-tableau">
{% if afficher_options != false %}
<label for="show_pres">afficher les présences</label>
{% if options.show_pres %}
<input type="checkbox" id="show_pres" name="show_pres" checked>
{% else %}
<input type="checkbox" id="show_pres" name="show_pres">
{% endif %}
<label for="show_reta">afficher les retards</label>
{% if options.show_reta %}
<input type="checkbox" id="show_reta" name="show_reta" checked>
{% else %}
<input type="checkbox" id="show_reta" name="show_reta">
{% endif %}
<label for="with_desc">afficher les descriptions</label>
{% if options.show_desc %}
<input type="checkbox" id="show_desc" name="show_desc" checked>
{% else %}
<input type="checkbox" id="show_desc" name="show_desc">
{% endif %}
<br>
{% endif %}
<label for="nb_ligne_page">Nombre de ligne par page : </label>
<input type="number" name="nb_ligne_page" id="nb_ligne_page" value="{{options.nb_ligne_page}}">
<label for="n_page">Page n°</label>
<select name="n_page" id="n_page">
{% for n in range(1,total_pages+1) %}
{% if n == options.page %}
<option value="{{n}}" selected>{{n}}</option>
{% else %}
<option value="{{n}}">{{n}}</option>
{% endif %}
{% endfor %}
</select>
<br>
<button onclick="updateTableau()">valider</button>
<a style="margin-left:32px;" href="{{request.url}}&fmt=xlsx">{{scu.ICON_XLS|safe}}</a>
</div>
</div>
{{tableau | safe}}
<script>
function updateTableau() {
const url = new URL(location.href);
const form = document.getElementById("options-tableau");
const formValues = form.querySelectorAll("*[name]");
formValues.forEach((el) => {
if (el.type == "checkbox") {
url.searchParams.set(el.name, el.checked)
} else {
url.searchParams.set(el.name, el.value)
}
})
location.href = url.href;
}
</script>
<style>
.small-font {
font-size: 9pt;
}
</style>

View File

@ -0,0 +1,106 @@
<h1>Détails {{type}} </h1>
<div id="informations">
<div class="info-row">
<span class="info-label">Étudiant.e concerné.e:</span> <span class="etudinfo"
id="etudid-{{objet.etudid}}">{{objet.etud_nom}}</span>
</div>
<div class="info-row">
<span class="info-label">Période concernée :</span> {{objet.date_debut}} au {{objet.date_fin}}
</div>
{% if type == "Assiduité" %}
<div class="info-row">
<span class="info-label">Module concernée :</span> {{objet.module}}
</div>
{% else %}
{% endif %}
<div class="info-row">
{% if type == "Justificatif" %}
<span class="info-label">État du justificatif :</span>
{% else %}
<span class="info-label">État de l'assiduité :</span>
{% endif %}
{{objet.etat}}
</div>
<div class="info-row">
{% if type == "Justificatif" %}
<div class="info-label">Raison:</div>
{% if objet.raison != None %}
<div class="text">{{objet.raison}}</div>
{% else %}
<div class="text">/div>
{% endif %}
{% else %}
<div class="info-label">Description:</div>
{% if objet.description != None %}
<div class="text">{{objet.description}}</div>
{% else %}
<div class="text"></div>
{% endif %}
{% endif %}
</div>
</div>
{# Affichage des justificatifs si assiduité justifiée #}
{% if type == "Assiduité" and objet.etat != "Présence" %}
<div class="info-row">
<span class="info-label">Justifiée: </span>
{% if objet.justification.est_just %}
<span class="text">Oui</span>
<div>
{% for justi in objet.justification.justificatifs %}
<a href="{{url_for('assiduites.tableau_assiduite_actions', type='justificatif', action='details', obj_id=justi.justif_id, scodoc_dept=g.scodoc_dept)}}"
target="_blank" rel="noopener noreferrer">Justificatif du {{justi.date_debut}} au {{justi.date_fin}}</a>
{% endfor %}
</div>
{% else %}
<span class="text">Non</span>
{% endif %}
</div>
{% endif %}
{# Affichage des assiduités justifiées si justificatif valide #}
{% if type == "Justificatif" and objet.etat == "Valide" %}
<div class="info-row">
<span class="info-label">Assiduités concernées: </span>
{% if objet.justification.assiduites %}
<div>
{% for assi in objet.justification.assiduites %}
<a href="{{url_for('assiduites.tableau_assiduite_actions', type='assiduite', action='details', obj_id=assi.assiduite_id, scodoc_dept=g.scodoc_dept)}}"
target="_blank">Assiduité {{assi.etat}} du {{assi.date_debut}} au
{{assi.date_fin}}</a>
{% endfor %}
</div>
{% else %}
<span class="text">Aucune</span>
{% endif %}
</div>
{% endif %}
{# Affichage des fichiers des justificatifs #}
{% if type == "Justificatif"%}
<div class="info-row">
<span class="info-label">Fichiers enregistrés: </span>
{% if objet.justification.fichiers.total != 0 %}
<div>Total : {{objet.justification.fichiers.total}} </div>
<ul>
{% for filename in objet.justification.fichiers.filenames %}
<li><a
href="{{url_for('api.justif_export',justif_id=objet.justif_id,filename=filename, scodoc_dept=g.scodoc_dept)}}">{{filename}}</a>
</li>
{% endfor %}
</ul>
{% else %}
<span class="text">Aucun</span>
{% endif %}
</div>
{% endif %}
<div class="info-row">
<span>Saisie par {{objet.saisie_par}} le {{objet.entry_date}}</span>
</div>

View File

@ -0,0 +1,107 @@
<h1>Modifier {{type}} </h1>
<form action="" method="post" enctype="multipart/form-data">
<input type="hidden" name="obj_id" value="{{obj_id}}">
<input type="hidden" name="table_url" id="table_url" value="">
{% if type == "Assiduité" %}
<input type="hidden" name="obj_type" value="assiduite">
<legend for="etat">État</legend>
<select name="etat" id="etat">
<option value="absent">Absent</option>
<option value="retard">Retard</option>
<option value="present">Présent</option>
</select>
<legend for="moduleimpl_select">Module</legend>
{{moduleimpl | safe}}
<legend for="description">Description</legend>
<textarea name="description" id="description" cols="50" rows="5">{{objet.description}}</textarea>
{% else %}
<input type="hidden" name="obj_type" value="justificatif">
<legend for="date_debut">Date de début</legend>
<scodoc-datetime name="date_debut" id="date_debut" value="{{objet.real_date_debut}}"></scodoc-datetime>
<legend for="date_fin">Date de fin</legend>
<scodoc-datetime name="date_fin" id="date_fin" value="{{objet.real_date_fin}}"></scodoc-datetime>
<legend for="etat">État</legend>
<select name="etat" id="etat">
<option value="valide">Valide</option>
<option value="non_valide">Non Valide</option>
<option value="attente">En Attente</option>
<option value="modifie">Modifié</option>
</select>
<legend for="raison">Raison</legend>
<textarea name="raison" id="raison" cols="50" rows="5">{{objet.raison}}</textarea>
<legend>Fichiers</legend>
<div class="info-row">
<label class="info-label">Fichiers enregistrés: </label>
{% if objet.justification.fichiers.total != 0 %}
<div>Total : {{objet.justification.fichiers.total}} </div>
<ul>
{% for filename in objet.justification.fichiers.filenames %}
<li data-id="{{filename}}">
<a data-file="{{filename}}">❌</a>
<a data-link=""
href="{{url_for('api.justif_export',justif_id=objet.justif_id,filename=filename, scodoc_dept=g.scodoc_dept)}}"><span
data-file="{{filename}}">{{filename}}</span></a>
</li>
{% endfor %}
</ul>
{% else %}
<span class="text">Aucun</span>
{% endif %}
</div>
<br>
<label for="justi_fich">Ajouter des fichiers:</label>
<input type="file" name="justi_fich" id="justi_fich" multiple>
{% endif %}
<br>
<br>
<input type="submit" value="Valider">
</form>
<script>
function removeFile(element) {
const link = document.querySelector(`*[data-id="${element.getAttribute('data-file')}"] a[data-link] span`);
link?.toggleAttribute("data-remove")
}
function deleteFiles(justif_id) {
const filenames = Array.from(document.querySelectorAll("*[data-remove]")).map((el) => el.getAttribute("data-file"))
obj = {
"remove": "list",
"filenames": filenames
}
//faire un POST à l'api justificatifs
}
window.addEventListener('load', () => {
document.getElementById('etat').value = "{{objet.real_etat}}";
document.getElementById('table_url').value = document.referrer;
document.querySelectorAll("a[data-file]").forEach((e) => {
e.addEventListener('click', () => {
removeFile(e);
})
})
})
</script>
<style>
[data-remove] {
text-decoration: line-through;
}
[data-file] {
cursor: pointer;
user-select: none;
}
</style>

View File

@ -32,6 +32,7 @@ from flask import abort, url_for, redirect
from flask_login import current_user from flask_login import current_user
from app import db from app import db
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.decorators import ( from app.decorators import (
@ -47,6 +48,10 @@ from app.models import (
Departement, Departement,
Evaluation, 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 assiduites_bp as bp
from app.views import ScoData 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.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
@ -260,13 +266,6 @@ def signal_assiduites_etud():
if etud.dept_id != g.scodoc_dept_id: if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département") 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) # gestion évaluations (Appel à la page depuis les évaluations)
saisie_eval: bool = request.args.get("saisie_eval") is not None 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) tableau = _preparer_tableau(
liste_assi.Data.from_etudiants(
morning = ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00") etud,
lunch = ScoDocSiteConfig.assi_get_rounded_time("assi_lunch_time", "13:00:00") ),
afternoon = ScoDocSiteConfig.assi_get_rounded_time( filename=f"assiduite-{etudid}",
"assi_afternoon_time", "18:00:00" afficher_etu=False,
filtre=liste_assi.Filtre(type_obj=1),
options=liste_assi.Options(show_module=True),
) )
if not tableau[0]:
# Gestion du selecteur de moduleimpl (pour le tableau différé) return tableau[1]
select = f"""
<select class="dynaSelect">
{render_template("assiduites/widgets/simplemoduleimpl_select.j2")}
</select>
"""
# Génération de la page # Génération de la page
return HTMLBuilder( return HTMLBuilder(
header, header,
@ -327,12 +322,12 @@ def signal_assiduites_etud():
), ),
assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"),
saisie_eval=saisie_eval, saisie_eval=saisie_eval,
date_deb=date_deb, date_deb=date_deb,
date_fin=date_fin, date_fin=date_fin,
redirect_url=redirect_url, redirect_url=redirect_url,
moduleimpl_id=moduleimpl_id, moduleimpl_id=moduleimpl_id,
tableau=tableau[1],
), ),
# render_template( # render_template(
# "assiduites/pages/signal_assiduites_etud.j2", # "assiduites/pages/signal_assiduites_etud.j2",
@ -378,7 +373,7 @@ def liste_assiduites_etud():
if etud.dept_id != g.scodoc_dept_id: if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département") 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) assiduite_id: int = request.args.get("assiduite_id", -1)
# Préparation de la page # Préparation de la page
@ -394,18 +389,25 @@ def liste_assiduites_etud():
"css/assiduites.css", "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 # Peuplement du template jinja
return HTMLBuilder( return HTMLBuilder(
header, header,
render_template( render_template(
"assiduites/pages/liste_assiduites.j2", "assiduites/pages/liste_assiduites.j2",
sco=ScoData(etud), sco=ScoData(etud),
date=datetime.date.today().isoformat(),
assi_id=assiduite_id, assi_id=assiduite_id,
assi_limit_annee=sco_preferences.get_preference( tableau=tableau[1],
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
),
), ),
).build() ).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 # Peuplement du template jinja
return HTMLBuilder( return HTMLBuilder(
header, header,
@ -514,6 +529,7 @@ def ajout_justificatif_etud():
), ),
assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"), assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"), assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"),
tableau=tableau[1],
), ),
).build() ).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") @bp.route("/SignalAssiduiteDifferee")
@scodoc @scodoc
@permission_required(Permission.AbsChange) @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 @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def test(): def testDateutils():
"""XXX fonction de test a retirer""" """XXX fonction de test a retirer"""
if request.method == "POST": if request.method == "POST":
print("test date_utils : ", request.form) 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 # 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" 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]] = [] modules: list[dict[str, str | int]] = []
# Récupération de l'id et d'un nom lisible pour chaque moduleimpl # Récupération de l'id et d'un nom lisible pour chaque moduleimpl
for modimpl in modimpls_list: for modimpl in modimpls_list: