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.file import MultipleFileField
from wtforms import (
BooleanField,
SelectField,
StringField,
SubmitField,
@ -136,6 +137,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Module",
choices={}, # will be populated dynamically
)
est_just = BooleanField("Justifiée")
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):

View File

@ -618,6 +618,7 @@ def compute_assiduites_justified(
Returns:
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
if justificatifs is None:
justificatifs: list[Justificatif] = Justificatif.query.filter_by(

View File

@ -390,13 +390,11 @@ def get_assiduites_stats(
# Récupération des états
etats: list[str] = (
filtered["etat"].split(",")
if "etat" in filtered
else ["absent", "present", "retard"]
filtered["etat"].split(",") if "etat" in filtered else scu.EtatAssiduite.all()
)
# ê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
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)
# Non utilisé
def invalidate_assiduites_count_sem(sem: dict):
"""Invalidate (clear) cached abs counts for all the students of this semestre"""
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"]
invalidate_assiduites_etud_date(etudid, date_debut)
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"
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 ",
"Mai ",
"Juin",
"Jul ",
"Juil ",
"Août",
"Sept",
"Oct ",

View File

@ -256,17 +256,17 @@
background-color: var(--color-conflit);
}
.etud_row .assiduites_bar .absent,
.etud_row .assiduites_bar>.absent,
.demo.absent {
background-color: var(--color-absent) !important;
}
.etud_row .assiduites_bar .present,
.etud_row .assiduites_bar>.present,
.demo.present {
background-color: var(--color-present) !important;
}
.etud_row .assiduites_bar .retard,
.etud_row .assiduites_bar>.retard,
.demo.retard {
background-color: var(--color-retard) !important;
}
@ -275,12 +275,12 @@
background-color: var(--color-nonwork) !important;
}
.etud_row .assiduites_bar .justified,
.etud_row .assiduites_bar>.justified,
.demo.justified {
background-image: var(--motif-justi);
}
.etud_row .assiduites_bar .invalid_justified,
.etud_row .assiduites_bar>.invalid_justified,
.demo.invalid_justified {
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 :
* - Groupe
@ -108,14 +127,16 @@ function validateSelectors(btn) {
return;
}
getAssiduitesFromEtuds(true);
// document.querySelector(".selectors").disabled = true;
// $("#tl_date").datepicker("option", "disabled", true);
generateMassAssiduites();
getAssiduitesFromEtuds(true);
generateAllEtudRow();
// btn.remove();
btn.textContent = "Actualiser";
btn.remove();
// Auto actualisation
$("#tl_date").on("change", updateEtudList);
$("#group_ids_sel").on("change", updateEtudList);
onlyAbs();
};
@ -648,16 +669,15 @@ function updateDate() {
);
openAlertModal("Attention", div, "", "#eec660");
/* BUG TODO MATHIAS
$(dateInput).datepicker("setDate", date_fra); // XXX ??? non définie
dateInput.value = date_fra;
*/
date = lastWorkDay;
dateStr = formatDate(lastWorkDay, {
dateStyle: "full",
timeZone: SCO_TIMEZONE,
}).capitalize();
$(dateInput).datepicker("setDate", date);
$(dateInput).change();
}
document.querySelector("#datestr").textContent = dateStr;

View File

@ -1,14 +1,74 @@
from datetime import datetime
from flask import url_for
from flask_sqlalchemy.query import Pagination, Query
from sqlalchemy import desc, literal, union
from flask_sqlalchemy.query import Query
from sqlalchemy import desc, literal, union, asc
from app import db, g
from app.auth.models import User
from app.models import Assiduite, Identite, Justificatif
from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool
from app.tables import table_builder as tb
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):
@ -18,13 +78,15 @@ class ListeAssiJusti(tb.Table):
"""
NB_PAR_PAGE: int = 25
MAX_PAR_PAGE: int = 200
MAX_PAR_PAGE: int = 1000
def __init__(
self,
table_data: "AssiJustifData",
filtre: "AssiFiltre" = None,
options: "AssiDisplayOptions" = None,
no_pagination: bool = False,
titre: str = "",
**kwargs,
) -> None:
"""
@ -41,11 +103,16 @@ class ListeAssiJusti(tb.Table):
# Gestion des options, par défaut un objet Options vide
self.options = options if options is not None else AssiDisplayOptions()
self.no_pagination: bool = no_pagination
self.total_page: int = None
# les lignes du tableau
self.rows: list["RowAssiJusti"] = []
# Titre du tableau, utilisé pour le cache
self.titre = titre
# Instanciation de la classe parent
super().__init__(
row_class=RowAssiJusti,
@ -65,59 +132,86 @@ class ListeAssiJusti(tb.Table):
# 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,
cle_cache: str = ":".join(
map(
str,
[
self.titre,
type_obj,
self.options.show_pres,
self.options.show_reta,
self.options.order[0],
self.options.order[1],
],
)
)
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
pagination: Pagination = self.paginer(query_finale)
self.total_pages: int = pagination.pages
pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination)
self.total_pages = pagination.total_pages
# 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.ajouter_colonnes()
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`.
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.
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
Cette méthode ne modifie pas la collection originelle; 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
return Pagination(
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):
@ -210,7 +304,7 @@ class ListeAssiJusti(tb.Table):
# 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"))
query_combinee = db.session.query(query_combinee)
return query_combinee
@ -241,30 +335,46 @@ class RowAssiJusti(tb.Row):
# Type d'objet
self._type()
# Date de début
multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date()
# En excel, on export les "vraes dates".
# En HTML, on écrit en français (on laisse les dates pour le tri)
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(
"date_debut",
"Date de début",
self.ligne["date_debut"].strftime("%d/%m/%y")
if multi_days
else self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"),
date_affichees[0],
data={"order": 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
self.add_cell(
"date_fin",
"Date de fin",
self.ligne["date_fin"].strftime("%d/%m/%y")
if multi_days
else self.ligne["date_fin"].strftime("à %H:%M"),
date_affichees[1],
raw_content=self.ligne["date_fin"], # Pour excel
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
@ -283,7 +393,11 @@ class RowAssiJusti(tb.Row):
data={"order": self.ligne["entry_date"] or ""},
raw_content=self.ligne["entry_date"],
classes=["small-font"],
column_classes={"entry_date"},
column_classes={
"entry_date",
"external-sort",
"external-type:entry_date",
},
)
def _type(self) -> None:
@ -541,6 +655,7 @@ class AssiDisplayOptions:
show_etu: str | bool = True,
show_actions: str | bool = True,
show_module: str | bool = False,
order: tuple[str, str | bool] = None,
):
self.page: int = page
self.nb_ligne_page: int = nb_ligne_page
@ -554,6 +669,10 @@ class AssiDisplayOptions:
self.show_actions = to_bool(show_actions)
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):
"Positionne options booléennes selon arguments"
for k, v in kwargs.items():
@ -565,6 +684,12 @@ class AssiDisplayOptions:
self.nb_ligne_page = min(
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:

View File

@ -87,6 +87,13 @@ div.submit > input {
{{ form.modimpl }}
{{ render_field_errors(form, 'modimpl') }}
</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 #}
<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" %}
<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 {
.couleurs.print {
@ -593,4 +390,4 @@
</script>
{% endblock pageContent %}
{% endblock app_content %}

View File

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

View File

@ -73,11 +73,6 @@
updateSelectedSelect(getCurrentAssiduiteModuleImplId());
updateJustifyBtn();
}
try {
if (isCalendrier()) {
window.location = `liste_assiduites_etud?etudid=${etudid}&assiduite_id=${assiduité.assiduite_id}`
}
} catch { }
});
//ajouter affichage assiduites on over
setupAssiduiteBuble(block, assiduité);
@ -138,51 +133,43 @@
*/
function setupAssiduiteBuble(el, assiduite) {
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");
idDiv.className = "assiduite-id";
idDiv.textContent = `${getModuleImpl(assiduite)}`;
bubble.appendChild(idDiv);
const idDiv = document.createElement("div");
idDiv.className = "assiduite-id";
idDiv.textContent = `${getModuleImpl(assiduite)}`;
bubble.appendChild(idDiv);
const periodDivDeb = document.createElement("div");
periodDivDeb.className = "assiduite-period";
periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`;
bubble.appendChild(periodDivDeb);
const periodDivFin = document.createElement("div");
periodDivFin.className = "assiduite-period";
periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`;
bubble.appendChild(periodDivFin);
const periodDivDeb = document.createElement("div");
periodDivDeb.className = "assiduite-period";
periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`;
bubble.appendChild(periodDivDeb);
const periodDivFin = document.createElement("div");
periodDivFin.className = "assiduite-period";
periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`;
bubble.appendChild(periodDivFin);
const stateDiv = document.createElement("div");
stateDiv.className = "assiduite-state";
stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`;
bubble.appendChild(stateDiv);
const stateDiv = document.createElement("div");
stateDiv.className = "assiduite-state";
stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`;
bubble.appendChild(stateDiv);
const userIdDiv = document.createElement("div");
userIdDiv.className = "assiduite-user_id";
userIdDiv.textContent = `saisie le ${formatDateModal(
assiduite.entry_date,
" à "
)}`;
const userIdDiv = document.createElement("div");
userIdDiv.className = "assiduite-user_id";
userIdDiv.textContent = `saisie le ${formatDateModal(
assiduite.entry_date,
" à "
)}`;
if (assiduite.user_id != null) {
userIdDiv.textContent += `\npar ${assiduite.user_nom_complet}`
}
bubble.appendChild(userIdDiv);
if (assiduite.user_id != null) {
userIdDiv.textContent += `\npar ${assiduite.user_nom_complet}`
}
bubble.appendChild(userIdDiv);
bubble.style.left = `${event.clientX - bubble.offsetWidth / 2}px`;
bubble.style.top = `${event.clientY + 20}px`;
});
el.addEventListener("mouseout", () => {
const bubble = document.querySelector(".assiduite-bubble");
bubble.classList.remove("is-active");
});
el.appendChild(bubble);
}
function setMiniTick(timelineDate, dayStart, dayDuration) {
@ -198,127 +185,4 @@
return tick
}
</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>
</script>

View File

@ -1,6 +1,6 @@
<div>
<div class="sco_box_title">{{ titre }}</div>
<div id="options-tableau">
<div class="options-tableau">
{% if afficher_options != false %}
<input type="checkbox" id="show_pres" name="show_pres"
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>
<br>
{% endif %}
<label for="nb_ligne_page">Nombre de lignes par page : </label>
<input type="number" name="nb_ligne_page" id="nb_ligne_page"
size="4" step="25" min="10" value="{{options.nb_ligne_page}}"
onchange="updateTableau()"
>
<label for="n_page">Page n°</label>
<select name="n_page" id="n_page">
{% for n in range(1,total_pages+1) %}
<option value="{{n}}" {{'selected' if n == options.page else ''}}>{{n}}</option>
<label for="nb_ligne_page">Nombre de lignes par page :</label>
<select name="nb_ligne_page" id="nb_ligne_page" onchange="updateTableau()">
{% for i in [25,50,100,1000] %}
{% if i == options.nb_ligne_page %}
<option selected value="{{i}}">{{i}}</option>
{% else %}
<option value="{{i}}">{{i}}</option>
{% endif %}
{% endfor %}
</select>
<br>
</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>
{{table.html() | safe}}
</div>
<script>
function updateTableau() {
const url = new URL(location.href);
const form = document.getElementById("options-tableau");
const formValues = form.querySelectorAll("*[name]");
const formValues = document.querySelectorAll(".options-tableau *[name]");
formValues.forEach((el) => {
if (el.type == "checkbox") {
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>
<style>
.small-font {
font-size: 9pt;
}
.div-tableau{
display: flex;
flex-direction: column;
align-items: center;
max-width: fit-content;
}
.pagination li{
cursor: pointer;
}
</style>

View File

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

View File

@ -324,6 +324,7 @@ def ajout_assiduite_etud() -> str | Response:
afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=1),
options=liste_assi.AssiDisplayOptions(show_module=True),
cache_key=f"tableau-etud-{etud.id}",
)
if not is_html:
return tableau
@ -461,11 +462,13 @@ def _record_assiduite_etud(
case _:
moduleimpl = ModuleImpl.query.get(moduleimpl_id)
try:
assi_etat: scu.EtatAssiduite = scu.EtatAssiduite.get(form.assi_etat.data)
ass = Assiduite.create_assiduite(
etud,
dt_debut_tz_server,
dt_fin_tz_server,
scu.EtatAssiduite.get(form.assi_etat.data),
assi_etat,
description=form.description.data,
entry_date=dt_entry_date_tz_server,
external_data=external_data,
@ -476,6 +479,19 @@ def _record_assiduite_etud(
db.session.add(ass)
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
scass.simple_invalidate_cache(ass.to_dict(), etud.id)
@ -524,10 +540,11 @@ def liste_assiduites_etud():
liste_assi.AssiJustifData.from_etudiants(
etud,
),
filename=f"assiduites-justificatifs-{etudid}",
filename=f"assiduites-justificatifs-{etud.id}",
afficher_etu=False,
filtre=liste_assi.AssiFiltre(type_obj=0),
options=liste_assi.AssiDisplayOptions(show_module=True),
cache_key=f"tableau-etud-{etud.id}",
)
if not tableau[0]:
return tableau[1]
@ -697,6 +714,7 @@ def ajout_justificatif_etud():
options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True),
afficher_options=False,
titre="Justificatifs enregistrés pour cet étudiant",
cache_key=f"tableau-etud-{etud.id}",
)
if not is_html:
return tableau
@ -860,36 +878,20 @@ def calendrier_assi_etud():
annees_str += f"{ann},"
annees_str += "]"
# Préparation de la page
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: dict[str, list["Jour"]] = generate_calendar(etud, annee)
calendrier = generate_calendar(etud, annee)
# Peuplement du template jinja
return HTMLBuilder(
header,
render_template(
"assiduites/pages/calendrier_assi_etud.j2",
sco=ScoData(etud),
annee=annee,
nonworkdays=_non_work_days(),
annees=annees_str,
calendrier=calendrier,
mode_demi=mode_demi,
show_pres=show_pres,
show_reta=show_reta,
),
).build()
return render_template(
"assiduites/pages/calendrier_assi_etud.j2",
sco=ScoData(etud),
annee=annee,
nonworkdays=_non_work_days(),
annees=annees_str,
calendrier=calendrier,
mode_demi=mode_demi,
show_pres=show_pres,
show_reta=show_reta,
)
@bp.route("/choix_date", methods=["GET", "POST"])
@ -924,7 +926,9 @@ def choix_date() -> str:
if ok:
return redirect(
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,
formsemestre_id=formsemestre_id,
group_ids=group_ids,
@ -1076,6 +1080,7 @@ def signal_assiduites_group():
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
"css/minitimeline.css",
],
)
@ -1173,13 +1178,19 @@ def visu_assiduites_group():
]
# --- Vérification de la date ---
real_date = scu.is_iso_formated(date, True).date()
if real_date < formsemestre.date_debut:
date = formsemestre.date_debut.isoformat()
elif real_date > formsemestre.date_fin:
date = formsemestre.date_fin.isoformat()
if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
# Si le jour est hors semestre, renvoyer vers choix date
return redirect(
url_for(
"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 ---
if moduleimpl_id:
@ -1223,6 +1234,7 @@ def visu_assiduites_group():
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
"css/minitimeline.css",
],
)
@ -1450,6 +1462,7 @@ def _prepare_tableau(
options: liste_assi.AssiDisplayOptions = None,
afficher_options: bool = True,
titre="Évènements enregistrés pour cet étudiant",
cache_key: str = "",
) -> tuple[bool, Response | str]:
"""
Prépare un tableau d'assiduités / justificatifs
@ -1486,6 +1499,13 @@ def _prepare_tableau(
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:
options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions()
@ -1496,12 +1516,15 @@ def _prepare_tableau(
show_reta=show_reta,
show_desc=show_desc,
show_etu=afficher_etu,
order=ordre,
)
table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
table_data=data,
options=options,
filtre=filtre,
no_pagination=fmt.startswith("xls"),
titre=cache_key,
)
if fmt.startswith("xls"):
@ -2297,7 +2320,7 @@ def _get_etuds_dem_def(formsemestre) -> str:
def generate_calendar(
etudiant: Identite,
annee: int = None,
):
) -> dict[str, list["Jour"]]:
# Si pas d'année alors on prend l'année scolaire en cours
if annee is None:
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
annee_par_mois: dict[int, list[datetime.date]] = _organize_by_month(
annee_par_mois: dict[str, list[Jour]] = _organize_by_month(
_get_dates_between(
deb=date_debut.date(),
fin=date_fin.date(),
@ -2333,32 +2356,6 @@ def generate_calendar(
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:
"""Jour
Jour du calendrier
@ -2371,8 +2368,8 @@ class Jour:
self.justificatifs = justificatifs
def get_nom(self, mode_demi: bool = True) -> str:
str_jour: str = WEEKDAYS.get(self.date.weekday())
return f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour}{self.date.day}"
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[:3]+' '}{self.date.day}"
def get_date(self) -> str:
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
def _organize_by_month(days, assiduites, justificatifs):
def _organize_by_month(days, assiduites, justificatifs) -> dict[str, list[Jour]]:
"""
Organiser les dates par mois.
"""
organized = {}
for date in days:
# Utiliser le numéro du mois comme clé
month = MONTHS.get(date.month)
# Récupérer le mois en français
month = scu.MONTH_NAMES_ABBREV[date.month - 1]
# Ajouter le jour à la liste correspondante au mois
if month not in organized:
organized[month] = []