This commit is contained in:
Emmanuel Viennet 2024-01-17 22:01:57 +01:00
commit f55f3fe82f
16 changed files with 677 additions and 544 deletions

View File

@ -32,6 +32,7 @@ Formulaire ajout d'un justificatif sur un étudiant
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import MultipleFileField from flask_wtf.file import MultipleFileField
from wtforms import ( from wtforms import (
BooleanField,
SelectField, SelectField,
StringField, StringField,
SubmitField, SubmitField,
@ -136,6 +137,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Module", "Module",
choices={}, # will be populated dynamically choices={}, # will be populated dynamically
) )
est_just = BooleanField("Justifiée")
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm): class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):

View File

@ -618,6 +618,7 @@ def compute_assiduites_justified(
Returns: Returns:
list[int]: la liste des assiduités qui ont été justifiées. list[int]: la liste des assiduités qui ont été justifiées.
""" """
# TODO à optimiser (car très long avec 40000 assiduités)
# Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant # Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant
if justificatifs is None: if justificatifs is None:
justificatifs: list[Justificatif] = Justificatif.query.filter_by( justificatifs: list[Justificatif] = Justificatif.query.filter_by(

View File

@ -390,13 +390,11 @@ def get_assiduites_stats(
# Récupération des états # Récupération des états
etats: list[str] = ( etats: list[str] = (
filtered["etat"].split(",") filtered["etat"].split(",") if "etat" in filtered else scu.EtatAssiduite.all()
if "etat" in filtered
else ["absent", "present", "retard"]
) )
# être sur que les états sont corrects # être sur que les états sont corrects
etats = [etat for etat in etats if etat in ["absent", "present", "retard"]] etats = [etat for etat in etats if etat.upper() in scu.EtatAssiduite.all()]
# Préparation du dictionnaire de retour avec les valeurs du calcul # Préparation du dictionnaire de retour avec les valeurs du calcul
count: dict = calculator.to_dict(only_total=False) count: dict = calculator.to_dict(only_total=False)
@ -688,6 +686,7 @@ def invalidate_assiduites_count(etudid: int, sem: dict):
sco_cache.AbsSemEtudCache.delete(key) sco_cache.AbsSemEtudCache.delete(key)
# Non utilisé
def invalidate_assiduites_count_sem(sem: dict): def invalidate_assiduites_count_sem(sem: dict):
"""Invalidate (clear) cached abs counts for all the students of this semestre""" """Invalidate (clear) cached abs counts for all the students of this semestre"""
inscriptions = ( inscriptions = (
@ -756,3 +755,8 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None):
etudid = etudid if etudid is not None else obj["etudid"] etudid = etudid if etudid is not None else obj["etudid"]
invalidate_assiduites_etud_date(etudid, date_debut) invalidate_assiduites_etud_date(etudid, date_debut)
invalidate_assiduites_etud_date(etudid, date_fin) invalidate_assiduites_etud_date(etudid, date_fin)
# Invalide les caches des tableaux de l'étudiant
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(
pattern=f"tableau-etud-{etudid}:*"
)

View File

@ -396,3 +396,13 @@ class ValidationsSemestreCache(ScoDocCache):
prefix = "VSC" prefix = "VSC"
timeout = 60 * 60 # ttl 1 heure (en phase de mise au point) timeout = 60 * 60 # ttl 1 heure (en phase de mise au point)
class RequeteTableauAssiduiteCache(ScoDocCache):
"""
clé : "<titre_tableau>:<type_obj>:<show_pres>:<show_retard>>:<order_col>:<order>"
Valeur = liste de dicts
"""
prefix = "TABASSI"
timeout = 60 * 60 # Une heure

View File

@ -476,7 +476,7 @@ MONTH_NAMES_ABBREV = (
"Avr ", "Avr ",
"Mai ", "Mai ",
"Juin", "Juin",
"Jul ", "Juil ",
"Août", "Août",
"Sept", "Sept",
"Oct ", "Oct ",

View File

@ -256,17 +256,17 @@
background-color: var(--color-conflit); background-color: var(--color-conflit);
} }
.etud_row .assiduites_bar .absent, .etud_row .assiduites_bar>.absent,
.demo.absent { .demo.absent {
background-color: var(--color-absent) !important; background-color: var(--color-absent) !important;
} }
.etud_row .assiduites_bar .present, .etud_row .assiduites_bar>.present,
.demo.present { .demo.present {
background-color: var(--color-present) !important; background-color: var(--color-present) !important;
} }
.etud_row .assiduites_bar .retard, .etud_row .assiduites_bar>.retard,
.demo.retard { .demo.retard {
background-color: var(--color-retard) !important; background-color: var(--color-retard) !important;
} }
@ -275,12 +275,12 @@
background-color: var(--color-nonwork) !important; background-color: var(--color-nonwork) !important;
} }
.etud_row .assiduites_bar .justified, .etud_row .assiduites_bar>.justified,
.demo.justified { .demo.justified {
background-image: var(--motif-justi); background-image: var(--motif-justi);
} }
.etud_row .assiduites_bar .invalid_justified, .etud_row .assiduites_bar>.invalid_justified,
.demo.invalid_justified { .demo.invalid_justified {
background-image: var(--motif-justi-invalide); background-image: var(--motif-justi-invalide);
} }

View File

@ -0,0 +1,212 @@
.day .dayline {
position: absolute;
display: none;
top: 100%;
z-index: 50;
width: max-content;
height: 75px;
background-color: #dedede;
border-radius: 15px;
padding: 5px;
}
.day:hover .dayline {
display: block;
}
.dayline .mini-timeline {
margin-top: 10%;
}
.dayline-title {
margin: 0;
}
.dayline .mini_tick {
position: absolute;
text-align: center;
top: 0;
transform: translateY(-110%);
z-index: 50;
}
.dayline .mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -69%;
z-index: 2;
transform: translateX(200%);
}
#label-nom,
#label-justi {
display: none;
}
.demi .day {
display: flex;
justify-content: space-evenly;
}
.demi .day>span {
display: block;
flex: 1;
text-align: center;
z-index: 1;
width: 100%;
border: 1px solid #d5d5d5;
position: relative;
}
.demi .day>span:first-of-type {
width: 3em;
min-width: 3em;
}
.options>* {
margin-right: 5px;
}
.options input {
margin-right: 6px;
}
.options label {
font-weight: normal;
margin-right: 16px;
}
/*Gestion des bubbles*/
.assiduite-bubble {
position: relative;
display: none;
background-color: #f9f9f9;
border-radius: 5px;
padding: 8px;
border: 3px solid #ccc;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-size: 12px;
line-height: 1.4;
z-index: 3;
min-width: max-content;
top: 200%;
}
.mini-timeline-block:hover .assiduite-bubble {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: auto;
max-height: 150px;
}
.assiduite-bubble::before {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent transparent #f9f9f9 transparent;
}
.assiduite-bubble::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent #ccc transparent;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
margin-bottom: 4px;
}
.assiduite-bubble.absent {
border-color: var(--color-absent) !important;
}
.assiduite-bubble.present {
border-color: var(--color-present) !important;
}
.assiduite-bubble.retard {
border-color: var(--color-retard) !important;
}
/*Gestion des minitimelines*/
.mini-timeline {
height: 7px;
border: 1px solid black;
position: relative;
background-color: white;
}
.mini-timeline.single {
height: 9px;
}
.mini-timeline-block {
position: absolute;
height: 100%;
display: flex;
justify-content: flex-start;
align-items: center;
flex-direction: column;
}
.mini-timeline-block {
cursor: pointer;
}
.mini_tick {
position: absolute;
text-align: start;
top: -40px;
transform: translateX(-50%);
z-index: 2;
}
.mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -2px;
z-index: 2;
}
.mini-timeline-block.creneau {
outline: 3px solid var(--color-primary);
pointer-events: none;
}
.mini-timeline-block.absent {
background-color: var(--color-absent) !important;
}
.mini-timeline-block.present {
background-color: var(--color-present) !important;
}
.mini-timeline-block.retard {
background-color: var(--color-retard) !important;
}
.mini-timeline-block.justified {
background-image: var(--motif-justi);
}
.mini-timeline-block.invalid_justified {
background-image: var(--motif-justi-invalide);
}

View File

@ -68,6 +68,25 @@ function setupCheckBox(parent = document) {
}); });
} }
function updateEtudList() {
const group_ids = getGroupIds();
etuds = {};
group_ids.forEach((group_id) => {
sync_get(getUrl() + `/api/group/${group_id}/etudiants`, (data, status) => {
if (status === "success") {
data.forEach((etud) => {
if (!(etud.id in etuds)) {
etuds[etud.id] = etud;
}
});
}
});
});
getAssiduitesFromEtuds(true);
generateAllEtudRow();
}
/** /**
* Validation préalable puis désactivation des chammps : * Validation préalable puis désactivation des chammps :
* - Groupe * - Groupe
@ -108,14 +127,16 @@ function validateSelectors(btn) {
return; return;
} }
getAssiduitesFromEtuds(true);
// document.querySelector(".selectors").disabled = true;
// $("#tl_date").datepicker("option", "disabled", true);
generateMassAssiduites(); generateMassAssiduites();
getAssiduitesFromEtuds(true);
generateAllEtudRow(); generateAllEtudRow();
// btn.remove();
btn.textContent = "Actualiser"; btn.remove();
// Auto actualisation
$("#tl_date").on("change", updateEtudList);
$("#group_ids_sel").on("change", updateEtudList);
onlyAbs(); onlyAbs();
}; };
@ -648,16 +669,15 @@ function updateDate() {
); );
openAlertModal("Attention", div, "", "#eec660"); openAlertModal("Attention", div, "", "#eec660");
/* BUG TODO MATHIAS
$(dateInput).datepicker("setDate", date_fra); // XXX ??? non définie
dateInput.value = date_fra;
*/
date = lastWorkDay; date = lastWorkDay;
dateStr = formatDate(lastWorkDay, { dateStr = formatDate(lastWorkDay, {
dateStyle: "full", dateStyle: "full",
timeZone: SCO_TIMEZONE, timeZone: SCO_TIMEZONE,
}).capitalize(); }).capitalize();
$(dateInput).datepicker("setDate", date);
$(dateInput).change();
} }
document.querySelector("#datestr").textContent = dateStr; document.querySelector("#datestr").textContent = dateStr;

View File

@ -1,14 +1,74 @@
from datetime import datetime from datetime import datetime
from flask import url_for from flask import url_for
from flask_sqlalchemy.query import Pagination, Query from flask_sqlalchemy.query import Query
from sqlalchemy import desc, literal, union from sqlalchemy import desc, literal, union, asc
from app import db, g from app import db, g
from app.auth.models import User from app.auth.models import User
from app.models import Assiduite, Identite, Justificatif from app.models import Assiduite, Identite, Justificatif
from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool
from app.tables import table_builder as tb from app.tables import table_builder as tb
from app.scodoc.sco_cache import RequeteTableauAssiduiteCache
class Pagination:
"""
Pagination d'une collection de données
On donne :
- une collection de données (de préférence une liste / tuple)
- le numéro de page à afficher
- le nombre d'éléments par page
On peut ensuite récupérer les éléments de la page courante avec la méthode `items()`
Cette classe ne permet pas de changer de page.
(Pour cela, il faut créer une nouvelle instance, avec la collection originelle et la nouvelle page)
l'intéret est de ne pas garder en mémoire toute la collection, mais seulement la page courante
"""
def __init__(self, collection: list, page: int = 1, per_page: int = -1):
"""
__init__ Instancie un nouvel objet Pagination
Args:
collection (list): La collection à paginer. Il s'agit par exemple d'une requête
page (int, optional): le numéro de la page à voir. Defaults to 1.
per_page (int, optional): le nombre d'éléments par page. Defaults to -1. (-1 = pas de pagination/tout afficher)
"""
# par défaut le total des pages est 1 (même si la collection est vide)
self.total_pages = 1
if per_page != -1:
# on récupère le nombre de page complète et le reste
# q => nombre de page
# r => le nombre d'éléments restants (dernière page si != 0)
q, r = len(collection) // per_page, len(collection) % per_page
self.total_pages = q if r == 0 else q + 1 # q + 1 s'il reste des éléments
# On s'assure que la page demandée est dans les limites
current_page: int = min(self.total_pages, page if page > 0 else 1)
# On récupère la collection de la page courante
self.collection = (
collection # toute la collection si pas de pagination
if per_page == -1
else collection[
per_page * (current_page - 1) : per_page * (current_page)
] # sinon on récupère la page
)
def items(self) -> list:
"""
items Renvoi la collection de la page courante
Returns:
list: la collection de la page courante
"""
return self.collection
class ListeAssiJusti(tb.Table): class ListeAssiJusti(tb.Table):
@ -18,13 +78,15 @@ class ListeAssiJusti(tb.Table):
""" """
NB_PAR_PAGE: int = 25 NB_PAR_PAGE: int = 25
MAX_PAR_PAGE: int = 200 MAX_PAR_PAGE: int = 1000
def __init__( def __init__(
self, self,
table_data: "AssiJustifData", table_data: "AssiJustifData",
filtre: "AssiFiltre" = None, filtre: "AssiFiltre" = None,
options: "AssiDisplayOptions" = None, options: "AssiDisplayOptions" = None,
no_pagination: bool = False,
titre: str = "",
**kwargs, **kwargs,
) -> None: ) -> None:
""" """
@ -41,11 +103,16 @@ class ListeAssiJusti(tb.Table):
# Gestion des options, par défaut un objet Options vide # Gestion des options, par défaut un objet Options vide
self.options = options if options is not None else AssiDisplayOptions() self.options = options if options is not None else AssiDisplayOptions()
self.no_pagination: bool = no_pagination
self.total_page: int = None self.total_page: int = None
# les lignes du tableau # les lignes du tableau
self.rows: list["RowAssiJusti"] = [] self.rows: list["RowAssiJusti"] = []
# Titre du tableau, utilisé pour le cache
self.titre = titre
# Instanciation de la classe parent # Instanciation de la classe parent
super().__init__( super().__init__(
row_class=RowAssiJusti, row_class=RowAssiJusti,
@ -65,59 +132,86 @@ class ListeAssiJusti(tb.Table):
# Récupération du filtrage des objets -> 0 : tout, 1 : Assi, 2: Justi # Récupération du filtrage des objets -> 0 : tout, 1 : Assi, 2: Justi
type_obj = self.filtre.type_obj() type_obj = self.filtre.type_obj()
if type_obj in [0, 1]: cle_cache: str = ":".join(
assiduites_query_etudiants = self.table_data.assiduites_query map(
str,
# Non affichage des présences [
if not self.options.show_pres: self.titre,
assiduites_query_etudiants = assiduites_query_etudiants.filter( type_obj,
Assiduite.etat != EtatAssiduite.PRESENT self.options.show_pres,
) self.options.show_reta,
# Non affichage des retards self.options.order[0],
if not self.options.show_reta: self.options.order[1],
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,
) )
r = RequeteTableauAssiduiteCache().get(cle_cache)
if r is None:
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,
)
# Tri de la query si option
if self.options.order is not None:
order_sort: str = asc if self.options.order[1] else desc
order_col: str = self.options.order[0]
query_finale: Query = query_finale.order_by(order_sort(order_col))
r = query_finale.all()
RequeteTableauAssiduiteCache.set(cle_cache, r)
# Paginer la requête pour ne pas envoyer trop d'informations au client # Paginer la requête pour ne pas envoyer trop d'informations au client
pagination: Pagination = self.paginer(query_finale) pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination)
self.total_pages: int = pagination.pages self.total_pages = pagination.total_pages
# Générer les lignes de la page # Générer les lignes de la page
for ligne in pagination.items: for ligne in pagination.items():
row: RowAssiJusti = self.row_class(self, ligne._asdict()) row: RowAssiJusti = self.row_class(self, ligne._asdict())
row.ajouter_colonnes() row.ajouter_colonnes()
self.add_row(row) self.add_row(row)
def paginer(self, query: Query) -> Pagination: def paginer(self, collection: list, no_pagination: bool = False) -> Pagination:
""" """
Applique la pagination à une requête SQLAlchemy en fonction des paramètres de la classe. Applique une pagination à une collection en fonction des paramètres de la classe.
Cette méthode prend une requête SQLAlchemy et applique la pagination en utilisant les Cette méthode prend une collection et applique la pagination en utilisant les
attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`. attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`.
Args: Args:
query (Query): La requête SQLAlchemy à paginer. Il s'agit d'une requête qui a déjà collection (list): La collection à paginer. Il s'agit par exemple d'une requête qui a déjà
été construite et qui est prête à être exécutée. été construite et qui est prête à être exécutée.
Returns: Returns:
Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée. Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée.
Note: Note:
Cette méthode ne modifie pas la requête originale; elle renvoie plutôt un nouvel Cette méthode ne modifie pas la collection originelle; elle renvoie plutôt un nouvel
objet qui contient les résultats paginés. objet qui contient les résultats paginés.
""" """
return query.paginate( return Pagination(
page=self.options.page, per_page=self.options.nb_ligne_page, error_out=False collection,
self.options.page,
-1 if no_pagination else self.options.nb_ligne_page,
) )
def joindre(self, query_assiduite: Query = None, query_justificatif: Query = None): def joindre(self, query_assiduite: Query = None, query_justificatif: Query = None):
@ -210,7 +304,7 @@ class ListeAssiJusti(tb.Table):
# Combiner les requêtes avec une union # Combiner les requêtes avec une union
query_combinee = union(*queries).alias("combinee") query_combinee = union(*queries).alias("combinee")
query_combinee = db.session.query(query_combinee).order_by(desc("date_debut")) query_combinee = db.session.query(query_combinee)
return query_combinee return query_combinee
@ -241,30 +335,46 @@ class RowAssiJusti(tb.Row):
# Type d'objet # Type d'objet
self._type() self._type()
# Date de début
multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date()
# En excel, on export les "vraes dates". # En excel, on export les "vraes dates".
# En HTML, on écrit en français (on laisse les dates pour le tri) # En HTML, on écrit en français (on laisse les dates pour le tri)
multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date()
date_affichees: list[str] = [
self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"), # date début
self.ligne["date_fin"].strftime("%d/%m/%y de %H:%M"), # date fin
]
if multi_days:
date_affichees[0] = self.ligne["date_debut"].strftime("%d/%m/%y")
date_affichees[1] = self.ligne["date_fin"].strftime("%d/%m/%y")
self.add_cell( self.add_cell(
"date_debut", "date_debut",
"Date de début", "Date de début",
self.ligne["date_debut"].strftime("%d/%m/%y") date_affichees[0],
if multi_days
else self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"),
data={"order": self.ligne["date_debut"]}, data={"order": self.ligne["date_debut"]},
raw_content=self.ligne["date_debut"], raw_content=self.ligne["date_debut"],
column_classes={"date", "date-debut"}, column_classes={
"date",
"date-debut",
"external-sort",
"external-type:date_debut",
},
) )
# Date de fin # Date de fin
self.add_cell( self.add_cell(
"date_fin", "date_fin",
"Date de fin", "Date de fin",
self.ligne["date_fin"].strftime("%d/%m/%y") date_affichees[1],
if multi_days
else self.ligne["date_fin"].strftime("à %H:%M"),
raw_content=self.ligne["date_fin"], # Pour excel raw_content=self.ligne["date_fin"], # Pour excel
data={"order": self.ligne["date_fin"]}, data={"order": self.ligne["date_fin"]},
column_classes={"date", "date-fin"}, column_classes={
"date",
"date-fin",
"external-sort",
"external-type:date_fin",
},
) )
# Ajout des colonnes optionnelles # Ajout des colonnes optionnelles
@ -283,7 +393,11 @@ class RowAssiJusti(tb.Row):
data={"order": self.ligne["entry_date"] or ""}, data={"order": self.ligne["entry_date"] or ""},
raw_content=self.ligne["entry_date"], raw_content=self.ligne["entry_date"],
classes=["small-font"], classes=["small-font"],
column_classes={"entry_date"}, column_classes={
"entry_date",
"external-sort",
"external-type:entry_date",
},
) )
def _type(self) -> None: def _type(self) -> None:
@ -541,6 +655,7 @@ class AssiDisplayOptions:
show_etu: str | bool = True, show_etu: str | bool = True,
show_actions: str | bool = True, show_actions: str | bool = True,
show_module: str | bool = False, show_module: str | bool = False,
order: tuple[str, str | bool] = None,
): ):
self.page: int = page self.page: int = page
self.nb_ligne_page: int = nb_ligne_page self.nb_ligne_page: int = nb_ligne_page
@ -554,6 +669,10 @@ class AssiDisplayOptions:
self.show_actions = to_bool(show_actions) self.show_actions = to_bool(show_actions)
self.show_module = to_bool(show_module) self.show_module = to_bool(show_module)
self.order = (
("date_debut", False) if order is None else (order[0], to_bool(order[1]))
)
def remplacer(self, **kwargs): def remplacer(self, **kwargs):
"Positionne options booléennes selon arguments" "Positionne options booléennes selon arguments"
for k, v in kwargs.items(): for k, v in kwargs.items():
@ -565,6 +684,12 @@ class AssiDisplayOptions:
self.nb_ligne_page = min( self.nb_ligne_page = min(
self.nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE self.nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE
) )
elif k == "order":
setattr(
self,
k,
("date_debut", False) if v is None else (v[0], to_bool(v[1])),
)
class AssiJustifData: class AssiJustifData:

View File

@ -87,6 +87,13 @@ div.submit > input {
{{ form.modimpl }} {{ form.modimpl }}
{{ render_field_errors(form, 'modimpl') }} {{ render_field_errors(form, 'modimpl') }}
</div> </div>
{# Justifiée #}
<div class="est-justifiee">
{{ form.est_just.label }}&nbsp;:
{{ form.est_just }}
<span class="help">génère un justificatif valide ayant la même période que l'assiduité signalée</span>
{{ render_field_errors(form, 'est_just') }}
</div>
{# Description #} {# Description #}
<div> <div>
<div>{{ form.description.label }}</div> <div>{{ form.description.label }}</div>

View File

@ -1,4 +1,14 @@
{% block pageContent %} {% extends "sco_page.j2" %}
{% block title %}
Calendrier de l'assiduité
{% endblock title %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="{{scu.STATIC_DIR}}/css/minitimeline.css">
<link rel="stylesheet" href="{{scu.STATIC_DIR}}/css/assiduites.css">
{% endblock styles %}
{% block app_content %}
{% include "assiduites/widgets/alert.j2" %} {% include "assiduites/widgets/alert.j2" %}
<div class="pageContent"> <div class="pageContent">
@ -250,219 +260,6 @@
} }
.day .dayline {
position: absolute;
display: none;
top: 100%;
z-index: 50;
width: max-content;
height: 75px;
background-color: #dedede;
border-radius: 15px;
padding: 5px;
}
.day:hover .dayline {
display: block;
}
.dayline .mini-timeline {
margin-top: 10%;
}
.dayline-title {
margin: 0;
}
.dayline .mini_tick {
position: absolute;
text-align: center;
top: 0;
transform: translateY(-110%);
z-index: 50;
}
.dayline .mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -69%;
z-index: 2;
transform: translateX(200%);
}
#label-nom,
#label-justi {
display: none;
}
.demi .day {
display: flex;
justify-content: space-evenly;
}
.demi .day>span {
display: block;
flex: 1;
text-align: center;
z-index: 1;
width: 100%;
border: 1px solid #d5d5d5;
position: relative;
}
.demi .day>span:first-of-type {
width: 3em;
min-width: 3em;
}
.options>* {
margin-right: 5px;
}
.options input {
margin-right: 6px;
}
.options label {
font-weight: normal;
margin-right: 16px;
}
/*Gestion des bubbles*/
.assiduite-bubble {
position: relative;
display: none;
background-color: #f9f9f9;
border-radius: 5px;
padding: 8px;
border: 3px solid #ccc;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-size: 12px;
line-height: 1.4;
z-index: 500;
min-width: max-content;
top: 200%;
}
.mini-timeline-block:hover .assiduite-bubble {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.assiduite-bubble::before {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent transparent #f9f9f9 transparent;
}
.assiduite-bubble::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent #ccc transparent;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
margin-bottom: 4px;
}
.assiduite-bubble.absent {
border-color: var(--color-absent) !important;
}
.assiduite-bubble.present {
border-color: var(--color-present) !important;
}
.assiduite-bubble.retard {
border-color: var(--color-retard) !important;
}
/*Gestion des minitimelines*/
.mini-timeline {
height: 7px;
border: 1px solid black;
position: relative;
background-color: white;
}
.mini-timeline.single {
height: 9px;
}
.mini-timeline-block {
position: absolute;
height: 100%;
z-index: 1;
display: flex;
justify-content: flex-start;
align-items: center;
flex-direction: column;
}
.mini-timeline-block {
cursor: pointer;
}
.mini_tick {
position: absolute;
text-align: start;
top: -40px;
transform: translateX(-50%);
z-index: 50;
}
.mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -2px;
z-index: 2;
}
.mini-timeline-block.creneau {
outline: 3px solid var(--color-primary);
pointer-events: none;
}
.mini-timeline-block.absent {
background-color: var(--color-absent) !important;
}
.mini-timeline-block.present {
background-color: var(--color-present) !important;
}
.mini-timeline-block.retard {
background-color: var(--color-retard) !important;
}
.mini-timeline-block.justified {
background-image: var(--motif-justi);
}
.mini-timeline-block.invalid_justified {
background-image: var(--motif-justi-invalide);
}
@media print { @media print {
.couleurs.print { .couleurs.print {
@ -593,4 +390,4 @@
</script> </script>
{% endblock pageContent %} {% endblock app_content %}

View File

@ -47,7 +47,6 @@
Faire la saisie Faire la saisie
</button> </button>
{% endif %} {% endif %}
<p>Utilisez le bouton "Actualiser" si vous modifier la date ou le(s) groupe(s) sélectionné(s)</p>
<div class="etud_holder"> <div class="etud_holder">
@ -97,9 +96,7 @@
updateDate(); updateDate();
if (!readOnly){ if (!readOnly){
setupTimeLine(()=>{ setupTimeLine(()=>{
if(document.querySelector('.etud_holder .placeholder') != null){
generateAllEtudRow(); generateAllEtudRow();
}
}); });
} }

View File

@ -73,11 +73,6 @@
updateSelectedSelect(getCurrentAssiduiteModuleImplId()); updateSelectedSelect(getCurrentAssiduiteModuleImplId());
updateJustifyBtn(); updateJustifyBtn();
} }
try {
if (isCalendrier()) {
window.location = `liste_assiduites_etud?etudid=${etudid}&assiduite_id=${assiduité.assiduite_id}`
}
} catch { }
}); });
//ajouter affichage assiduites on over //ajouter affichage assiduites on over
setupAssiduiteBuble(block, assiduité); setupAssiduiteBuble(block, assiduité);
@ -138,51 +133,43 @@
*/ */
function setupAssiduiteBuble(el, assiduite) { function setupAssiduiteBuble(el, assiduite) {
if (!assiduite) return; if (!assiduite) return;
el.addEventListener("mouseenter", (event) => {
const bubble = document.querySelector(".assiduite-bubble");
bubble.className = "assiduite-bubble";
bubble.classList.add("is-active", assiduite.etat.toLowerCase());
bubble.innerHTML = ""; const bubble = document.createElement('div');
bubble.className = "assiduite-bubble";
bubble.classList.add(assiduite.etat.toLowerCase());
const idDiv = document.createElement("div"); const idDiv = document.createElement("div");
idDiv.className = "assiduite-id"; idDiv.className = "assiduite-id";
idDiv.textContent = `${getModuleImpl(assiduite)}`; idDiv.textContent = `${getModuleImpl(assiduite)}`;
bubble.appendChild(idDiv); bubble.appendChild(idDiv);
const periodDivDeb = document.createElement("div"); const periodDivDeb = document.createElement("div");
periodDivDeb.className = "assiduite-period"; periodDivDeb.className = "assiduite-period";
periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`; periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`;
bubble.appendChild(periodDivDeb); bubble.appendChild(periodDivDeb);
const periodDivFin = document.createElement("div"); const periodDivFin = document.createElement("div");
periodDivFin.className = "assiduite-period"; periodDivFin.className = "assiduite-period";
periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`; periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`;
bubble.appendChild(periodDivFin); bubble.appendChild(periodDivFin);
const stateDiv = document.createElement("div"); const stateDiv = document.createElement("div");
stateDiv.className = "assiduite-state"; stateDiv.className = "assiduite-state";
stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`; stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`;
bubble.appendChild(stateDiv); bubble.appendChild(stateDiv);
const userIdDiv = document.createElement("div"); const userIdDiv = document.createElement("div");
userIdDiv.className = "assiduite-user_id"; userIdDiv.className = "assiduite-user_id";
userIdDiv.textContent = `saisie le ${formatDateModal( userIdDiv.textContent = `saisie le ${formatDateModal(
assiduite.entry_date, assiduite.entry_date,
" à " " à "
)}`; )}`;
if (assiduite.user_id != null) { if (assiduite.user_id != null) {
userIdDiv.textContent += `\npar ${assiduite.user_nom_complet}` userIdDiv.textContent += `\npar ${assiduite.user_nom_complet}`
} }
bubble.appendChild(userIdDiv); bubble.appendChild(userIdDiv);
bubble.style.left = `${event.clientX - bubble.offsetWidth / 2}px`; el.appendChild(bubble);
bubble.style.top = `${event.clientY + 20}px`;
});
el.addEventListener("mouseout", () => {
const bubble = document.querySelector(".assiduite-bubble");
bubble.classList.remove("is-active");
});
} }
function setMiniTick(timelineDate, dayStart, dayDuration) { function setMiniTick(timelineDate, dayStart, dayDuration) {
@ -198,127 +185,4 @@
return tick return tick
} }
</script> </script>
<style>
.assiduite-bubble {
position: fixed;
display: none;
background-color: #f9f9f9;
border-radius: 5px;
padding: 8px;
border: 3px solid #ccc;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-size: 12px;
line-height: 1.4;
z-index: 500;
}
.assiduite-bubble.is-active {
display: block;
}
.assiduite-bubble::before {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent transparent #f9f9f9 transparent;
}
.assiduite-bubble::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent #ccc transparent;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
margin-bottom: 4px;
}
.assiduite-bubble.absent {
border-color: var(--color-absent) !important;
}
.assiduite-bubble.present {
border-color: var(--color-present) !important;
}
.assiduite-bubble.retard {
border-color: var(--color-retard) !important;
}
.mini-timeline {
height: 7px;
border: 1px solid black;
position: relative;
background-color: white;
}
.mini-timeline.single {
height: 9px;
}
.mini-timeline-block {
position: absolute;
height: 100%;
z-index: 1;
}
#page-assiduite-content .mini-timeline-block {
cursor: pointer;
}
.mini_tick {
position: absolute;
text-align: start;
top: -40px;
transform: translateX(-50%);
z-index: 1;
}
.mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -2px;
z-index: 2;
}
.mini-timeline-block.creneau {
outline: 3px solid var(--color-primary);
pointer-events: none;
}
.mini-timeline-block.absent {
background-color: var(--color-absent) !important;
}
.mini-timeline-block.present {
background-color: var(--color-present) !important;
}
.mini-timeline-block.retard {
background-color: var(--color-retard) !important;
}
.mini-timeline-block.justified {
background-image: var(--motif-justi);
}
.mini-timeline-block.invalid_justified {
background-image: var(--motif-justi-invalide);
}
</style>

View File

@ -1,6 +1,6 @@
<div> <div>
<div class="sco_box_title">{{ titre }}</div> <div class="sco_box_title">{{ titre }}</div>
<div id="options-tableau"> <div class="options-tableau">
{% if afficher_options != false %} {% if afficher_options != false %}
<input type="checkbox" id="show_pres" name="show_pres" <input type="checkbox" id="show_pres" name="show_pres"
onclick="updateTableau()" {{'checked' if options.show_pres else ''}}> onclick="updateTableau()" {{'checked' if options.show_pres else ''}}>
@ -17,33 +17,84 @@
<a style="margin-left:32px;" href="{{request.url}}&fmt=xlsx">{{scu.ICON_XLS|safe}}</a> <a style="margin-left:32px;" href="{{request.url}}&fmt=xlsx">{{scu.ICON_XLS|safe}}</a>
<br> <br>
{% endif %} {% endif %}
<label for="nb_ligne_page">Nombre de lignes par page : </label> <label for="nb_ligne_page">Nombre de lignes par page :</label>
<input type="number" name="nb_ligne_page" id="nb_ligne_page" <select name="nb_ligne_page" id="nb_ligne_page" onchange="updateTableau()">
size="4" step="25" min="10" value="{{options.nb_ligne_page}}" {% for i in [25,50,100,1000] %}
onchange="updateTableau()" {% if i == options.nb_ligne_page %}
> <option selected value="{{i}}">{{i}}</option>
{% else %}
<label for="n_page">Page n°</label> <option value="{{i}}">{{i}}</option>
<select name="n_page" id="n_page"> {% endif %}
{% for n in range(1,total_pages+1) %}
<option value="{{n}}" {{'selected' if n == options.page else ''}}>{{n}}</option>
{% endfor %} {% endfor %}
</select> </select>
<br> <br>
</div> </div>
<div class="div-tableau">
{{table.html() | safe}}
<div class="options-tableau">
<!--Pagination basée sur : https://app.uxcel.com/courses/ui-components-best-practices/best-practices-005 -->
<!-- Mettre les flèches -->
{% if total_pages > 1 %}
<ul class="pagination">
<li class="">
<a onclick="navigateToPage({{options.page - 1}})">&lt;</a>
</li>
<!-- Toujours afficher la première page -->
<li class="{% if options.page == 1 %}active{% endif %}">
<a onclick="navigateToPage({{1}})">1</a>
</li>
<!-- Afficher les ellipses si la page courante est supérieure à 2 -->
<!-- et qu'il y a plus d'une page entre le 1 et la page courante-1 -->
{% if options.page > 2 and (options.page - 1) - 1 > 1 %}
<li class="disabled"><span>...</span></li>
{% endif %}
<!-- Afficher la page précédente, la page courante, et la page suivante -->
{% for i in range(options.page - 1, options.page + 2) %}
{% if i > 1 and i < total_pages %}
<li class="{% if options.page == i %}active{% endif %}">
<a onclick="navigateToPage({{i}})">{{ i }}</a>
</li>
{% endif %}
{% endfor %}
<!-- Afficher les ellipses si la page courante est inférieure à l'avant-dernière page -->
<!-- et qu'il y a plus d'une page entre le total_pages et la page courante+1 -->
{% if options.page < total_pages - 1 and total_pages - (options.page + 1 ) > 1 %}
<li class="disabled"><span>...</span></li>
{% endif %}
<!-- Toujours afficher la dernière page -->
<li class="{% if options.page == total_pages %}active{% endif %}">
<a onclick="navigateToPage({{total_pages}})">{{ total_pages }}</a>
</li>
<li class="">
<a onclick="navigateToPage({{options.page + 1}})">&gt;</a>
</li>
</ul>
{% else %}
<!-- Afficher un seul bouton si il n'y a qu'une seule page -->
<ul class="pagination">
<li class="active"><a onclick="navigateToPage({{1}})">1</a></li>
</ul>
{% endif %}
</div>
</div> </div>
{{table.html() | safe}} </div>
<script> <script>
function updateTableau() { function updateTableau() {
const url = new URL(location.href); const url = new URL(location.href);
const form = document.getElementById("options-tableau"); const formValues = document.querySelectorAll(".options-tableau *[name]");
const formValues = form.querySelectorAll("*[name]");
formValues.forEach((el) => { formValues.forEach((el) => {
if (el.type == "checkbox") { if (el.type == "checkbox") {
url.searchParams.set(el.name, el.checked) url.searchParams.set(el.name, el.checked)
@ -58,10 +109,56 @@
} }
} }
const total_pages = {{total_pages}};
function navigateToPage(pageNumber){
if(pageNumber > total_pages || pageNumber < 1) return;
const url = new URL(location.href);
url.searchParams.set("n_page", pageNumber)
if (!url.href.endsWith("#options-tableau")) {
location.href = url.href + "#options-tableau";
} else {
location.href = url.href;
}
}
window.addEventListener('load', ()=>{
const table_columns = [...document.querySelectorAll('.external-sort')];
table_columns.forEach((e)=>e.addEventListener('click', ()=>{
// récupération de l'ordre "ascending" / "descending"
let order = e.ariaSort;
// récupération de la colonne à ordonner
// il faut avoir une classe `external-type:<NOM COL>`
let order_col = e.className.split(" ").find((e)=>e.indexOf("external-type:") != -1);
//Création de la nouvelle url avec le tri
const url = new URL(location.href);
url.searchParams.set("order", order);
url.searchParams.set("order_col", order_col.split(":")[1]);
location.href = url.href
}));
});
</script> </script>
<style> <style>
.small-font { .small-font {
font-size: 9pt; font-size: 9pt;
} }
.div-tableau{
display: flex;
flex-direction: column;
align-items: center;
max-width: fit-content;
}
.pagination li{
cursor: pointer;
}
</style> </style>

View File

@ -89,8 +89,7 @@
} }
function timelineMainEvent(event, callback) { function timelineMainEvent(event) {
const func_call = callback ? callback : () => { };
const startX = (event.clientX || event.changedTouches[0].clientX); const startX = (event.clientX || event.changedTouches[0].clientX);
@ -152,7 +151,6 @@
updatePeriodTimeLabel(); updatePeriodTimeLabel();
}; };
const mouseUp = () => { const mouseUp = () => {
generateAllEtudRow();
snapHandlesToQuarters(); snapHandlesToQuarters();
timelineContainer.removeEventListener("mousemove", onMouseMove); timelineContainer.removeEventListener("mousemove", onMouseMove);
func_call(); func_call();
@ -172,9 +170,12 @@
} }
} }
let func_call = () => { };
function setupTimeLine(callback) { function setupTimeLine(callback) {
timelineContainer.addEventListener("mousedown", (e) => { timelineMainEvent(e, callback) }); func_call = callback;
timelineContainer.addEventListener("touchstart", (e) => { timelineMainEvent(e, callback) }); timelineContainer.addEventListener("mousedown", (e) => { timelineMainEvent(e) });
timelineContainer.addEventListener("touchstart", (e) => { timelineMainEvent(e) });
} }
function adjustPeriodPosition(newLeft, newWidth) { function adjustPeriodPosition(newLeft, newWidth) {
@ -230,8 +231,8 @@
periodTimeLine.style.width = `${widthPercentage}%`; periodTimeLine.style.width = `${widthPercentage}%`;
snapHandlesToQuarters(); snapHandlesToQuarters();
generateAllEtudRow();
updatePeriodTimeLabel() updatePeriodTimeLabel()
func_call();
} }
function snapHandlesToQuarters() { function snapHandlesToQuarters() {
@ -270,7 +271,6 @@
if (heure_deb != '' && heure_fin != '') { if (heure_deb != '' && heure_fin != '') {
heure_deb = fromTime(heure_deb); heure_deb = fromTime(heure_deb);
heure_fin = fromTime(heure_fin); heure_fin = fromTime(heure_fin);
console.warn(heure_deb, heure_fin)
setPeriodValues(heure_deb, heure_fin) setPeriodValues(heure_deb, heure_fin)
} }
{% endif %} {% endif %}

View File

@ -324,6 +324,7 @@ def ajout_assiduite_etud() -> str | Response:
afficher_etu=False, afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=1), filtre=liste_assi.AssiFiltre(type_obj=1),
options=liste_assi.AssiDisplayOptions(show_module=True), options=liste_assi.AssiDisplayOptions(show_module=True),
cache_key=f"tableau-etud-{etud.id}",
) )
if not is_html: if not is_html:
return tableau return tableau
@ -461,11 +462,13 @@ def _record_assiduite_etud(
case _: case _:
moduleimpl = ModuleImpl.query.get(moduleimpl_id) moduleimpl = ModuleImpl.query.get(moduleimpl_id)
try: try:
assi_etat: scu.EtatAssiduite = scu.EtatAssiduite.get(form.assi_etat.data)
ass = Assiduite.create_assiduite( ass = Assiduite.create_assiduite(
etud, etud,
dt_debut_tz_server, dt_debut_tz_server,
dt_fin_tz_server, dt_fin_tz_server,
scu.EtatAssiduite.get(form.assi_etat.data), assi_etat,
description=form.description.data, description=form.description.data,
entry_date=dt_entry_date_tz_server, entry_date=dt_entry_date_tz_server,
external_data=external_data, external_data=external_data,
@ -476,6 +479,19 @@ def _record_assiduite_etud(
db.session.add(ass) db.session.add(ass)
db.session.commit() db.session.commit()
if assi_etat != scu.EtatAssiduite.PRESENT and form.est_just.data:
# si la case "justifiée est cochée alors on créé un justificatif de même période"
justi: Justificatif = Justificatif.create_justificatif(
etudiant=etud,
date_debut=dt_debut_tz_server,
date_fin=dt_fin_tz_server,
etat=scu.EtatJustificatif.VALIDE,
user_id=current_user.id,
)
# On met à jour les assiduités en fonction du nouveau justificatif
compute_assiduites_justified(etud.id, [justi])
# Invalider cache # Invalider cache
scass.simple_invalidate_cache(ass.to_dict(), etud.id) scass.simple_invalidate_cache(ass.to_dict(), etud.id)
@ -524,10 +540,11 @@ def liste_assiduites_etud():
liste_assi.AssiJustifData.from_etudiants( liste_assi.AssiJustifData.from_etudiants(
etud, etud,
), ),
filename=f"assiduites-justificatifs-{etudid}", filename=f"assiduites-justificatifs-{etud.id}",
afficher_etu=False, afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=0), filtre=liste_assi.AssiFiltre(type_obj=0),
options=liste_assi.AssiDisplayOptions(show_module=True), options=liste_assi.AssiDisplayOptions(show_module=True),
cache_key=f"tableau-etud-{etud.id}",
) )
if not tableau[0]: if not tableau[0]:
return tableau[1] return tableau[1]
@ -697,6 +714,7 @@ def ajout_justificatif_etud():
options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True), options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True),
afficher_options=False, afficher_options=False,
titre="Justificatifs enregistrés pour cet étudiant", titre="Justificatifs enregistrés pour cet étudiant",
cache_key=f"tableau-etud-{etud.id}",
) )
if not is_html: if not is_html:
return tableau return tableau
@ -860,36 +878,20 @@ def calendrier_assi_etud():
annees_str += f"{ann}," annees_str += f"{ann},"
annees_str += "]" annees_str += "]"
# Préparation de la page calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee)
header: str = html_sco_header.sco_header(
page_title="Calendrier de l'assiduité",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"js/date_utils.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
calendrier = generate_calendar(etud, annee)
# Peuplement du template jinja # Peuplement du template jinja
return HTMLBuilder( return render_template(
header, "assiduites/pages/calendrier_assi_etud.j2",
render_template( sco=ScoData(etud),
"assiduites/pages/calendrier_assi_etud.j2", annee=annee,
sco=ScoData(etud), nonworkdays=_non_work_days(),
annee=annee, annees=annees_str,
nonworkdays=_non_work_days(), calendrier=calendrier,
annees=annees_str, mode_demi=mode_demi,
calendrier=calendrier, show_pres=show_pres,
mode_demi=mode_demi, show_reta=show_reta,
show_pres=show_pres, )
show_reta=show_reta,
),
).build()
@bp.route("/choix_date", methods=["GET", "POST"]) @bp.route("/choix_date", methods=["GET", "POST"])
@ -924,7 +926,9 @@ def choix_date() -> str:
if ok: if ok:
return redirect( return redirect(
url_for( url_for(
"assiduites.signal_assiduites_group", "assiduites.signal_assiduites_group"
if request.args.get("readonly") is None
else "assiduites.visu_assiduites_group",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id, formsemestre_id=formsemestre_id,
group_ids=group_ids, group_ids=group_ids,
@ -1076,6 +1080,7 @@ def signal_assiduites_group():
cssstyles=CSSSTYLES cssstyles=CSSSTYLES
+ [ + [
"css/assiduites.css", "css/assiduites.css",
"css/minitimeline.css",
], ],
) )
@ -1173,13 +1178,19 @@ def visu_assiduites_group():
] ]
# --- Vérification de la date --- # --- Vérification de la date ---
real_date = scu.is_iso_formated(date, True).date() real_date = scu.is_iso_formated(date, True).date()
if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
if real_date < formsemestre.date_debut: # Si le jour est hors semestre, renvoyer vers choix date
date = formsemestre.date_debut.isoformat() return redirect(
elif real_date > formsemestre.date_fin: url_for(
date = formsemestre.date_fin.isoformat() "assiduites.choix_date",
formsemestre_id=formsemestre_id,
group_ids=group_ids,
moduleimpl_id=moduleimpl_id,
scodoc_dept=g.scodoc_dept,
readonly="true",
)
)
# --- Restriction en fonction du moduleimpl_id --- # --- Restriction en fonction du moduleimpl_id ---
if moduleimpl_id: if moduleimpl_id:
@ -1223,6 +1234,7 @@ def visu_assiduites_group():
cssstyles=CSSSTYLES cssstyles=CSSSTYLES
+ [ + [
"css/assiduites.css", "css/assiduites.css",
"css/minitimeline.css",
], ],
) )
@ -1450,6 +1462,7 @@ def _prepare_tableau(
options: liste_assi.AssiDisplayOptions = None, options: liste_assi.AssiDisplayOptions = None,
afficher_options: bool = True, afficher_options: bool = True,
titre="Évènements enregistrés pour cet étudiant", titre="Évènements enregistrés pour cet étudiant",
cache_key: str = "",
) -> tuple[bool, Response | str]: ) -> tuple[bool, Response | str]:
""" """
Prépare un tableau d'assiduités / justificatifs Prépare un tableau d'assiduités / justificatifs
@ -1486,6 +1499,13 @@ def _prepare_tableau(
fmt = request.args.get("fmt", "html") fmt = request.args.get("fmt", "html")
# Ordre
ordre: tuple[str, str | bool] = None
ordre_col: str = request.args.get("order_col", None)
ordre_tri: str = request.args.get("order", None)
if ordre_col is not None and ordre_tri is not None:
ordre = (ordre_col, ordre_tri == "ascending")
if options is None: if options is None:
options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions() options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions()
@ -1496,12 +1516,15 @@ def _prepare_tableau(
show_reta=show_reta, show_reta=show_reta,
show_desc=show_desc, show_desc=show_desc,
show_etu=afficher_etu, show_etu=afficher_etu,
order=ordre,
) )
table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
table_data=data, table_data=data,
options=options, options=options,
filtre=filtre, filtre=filtre,
no_pagination=fmt.startswith("xls"),
titre=cache_key,
) )
if fmt.startswith("xls"): if fmt.startswith("xls"):
@ -2297,7 +2320,7 @@ def _get_etuds_dem_def(formsemestre) -> str:
def generate_calendar( def generate_calendar(
etudiant: Identite, etudiant: Identite,
annee: int = None, annee: int = None,
): ) -> dict[str, list["Jour"]]:
# Si pas d'année alors on prend l'année scolaire en cours # Si pas d'année alors on prend l'année scolaire en cours
if annee is None: if annee is None:
annee = scu.annee_scolaire() annee = scu.annee_scolaire()
@ -2321,7 +2344,7 @@ def generate_calendar(
) )
# Récupération des jours de l'année et de leurs assiduités/justificatifs # Récupération des jours de l'année et de leurs assiduités/justificatifs
annee_par_mois: dict[int, list[datetime.date]] = _organize_by_month( annee_par_mois: dict[str, list[Jour]] = _organize_by_month(
_get_dates_between( _get_dates_between(
deb=date_debut.date(), deb=date_debut.date(),
fin=date_fin.date(), fin=date_fin.date(),
@ -2333,32 +2356,6 @@ def generate_calendar(
return annee_par_mois return annee_par_mois
WEEKDAYS = {
0: "Lun ",
1: "Mar ",
2: "Mer ",
3: "Jeu ",
4: "Ven ",
5: "Sam ",
6: "Dim ",
}
MONTHS = {
1: "Janv.",
2: "Févr.",
3: "Mars",
4: "Avr.",
5: "Mai",
6: "Juin",
7: "Juil.",
8: "Août",
9: "Sept.",
10: "Oct.",
11: "Nov.",
12: "Déc.",
}
class Jour: class Jour:
"""Jour """Jour
Jour du calendrier Jour du calendrier
@ -2371,8 +2368,8 @@ class Jour:
self.justificatifs = justificatifs self.justificatifs = justificatifs
def get_nom(self, mode_demi: bool = True) -> str: def get_nom(self, mode_demi: bool = True) -> str:
str_jour: str = WEEKDAYS.get(self.date.weekday()) str_jour: str = scu.DAY_NAMES[self.date.weekday()].capitalize()
return f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour}{self.date.day}" return f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour[:3]+' '}{self.date.day}"
def get_date(self) -> str: def get_date(self) -> str:
return self.date.strftime("%d/%m/%Y") return self.date.strftime("%d/%m/%Y")
@ -2584,14 +2581,14 @@ def _get_dates_between(deb: datetime.date, fin: datetime.date) -> list[datetime.
return resultat return resultat
def _organize_by_month(days, assiduites, justificatifs): def _organize_by_month(days, assiduites, justificatifs) -> dict[str, list[Jour]]:
""" """
Organiser les dates par mois. Organiser les dates par mois.
""" """
organized = {} organized = {}
for date in days: for date in days:
# Utiliser le numéro du mois comme clé # Récupérer le mois en français
month = MONTHS.get(date.month) month = scu.MONTH_NAMES_ABBREV[date.month - 1]
# Ajouter le jour à la liste correspondante au mois # Ajouter le jour à la liste correspondante au mois
if month not in organized: if month not in organized:
organized[month] = [] organized[month] = []