Compare commits

...

9 Commits

21 changed files with 596 additions and 915 deletions

View File

@ -161,3 +161,30 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")
class ChoixDateForm(FlaskForm):
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = [] # used to report our errors
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
date = StringField(
"Date",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "date",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -4,6 +4,7 @@
from datetime import datetime
from flask_login import current_user
from flask_sqlalchemy.query import Query
from sqlalchemy.exc import DataError
from app import db, log, g, set_sco_dept
from app.models import (
@ -249,50 +250,58 @@ class Assiduite(ScoDocModel):
sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut)
return nouv_assiduite
def set_moduleimpl(self, moduleimpl_id: int | str) -> bool:
"""TODO"""
# je ne comprend pas cette fonction WIP
# moduleimpl_id peut être == "autre", ce qui plante
# ci-dessous un fix temporaire en attendant explication de @iziram
if moduleimpl_id is None:
raise ScoValueError("invalid moduleimpl_id")
def set_moduleimpl(self, moduleimpl_id: int | str):
"""Mise à jour du moduleimpl_id
Les valeurs du champs "moduleimpl_id" possibles sont :
- <int> (un id classique)
- <str> ("autre" ou "<id>")
- None (pas de moduleimpl_id)
Si la valeur est "autre" il faut:
- mettre à None assiduité.moduleimpl_id
- mettre à jour assiduite.external_data["module"] = "autre"
En fonction de la configuration du semestre la valeur `None` peut-être considérée comme invalide.
- Il faudra donc vérifier que ce n'est pas le cas avant de mettre à jour l'assiduité
"""
moduleimpl: ModuleImpl = None
try:
moduleimpl_id_int = int(moduleimpl_id)
except ValueError as exc:
raise ScoValueError("invalid moduleimpl_id") from exc
# /fix
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id_int)
if moduleimpl is not None:
# Vérification de l'inscription de l'étudiant
# ne lève une erreur que si moduleimpl_id est une chaine de caractère non parsable (parseInt)
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
# moduleImpl est soit :
# - None si moduleimpl_id==None
# - None si moduleimpl_id==<int> non reconnu
# - ModuleImpl si <int|str> valide
# Vérification ModuleImpl not None (raise ScoValueError)
if moduleimpl is None and self._check_force_module(moduleimpl):
# Ici uniquement si on est autorisé à ne pas avoir de module
self.moduleimpl_id = None
return
# Vérification Inscription ModuleImpl (raise ScoValueError)
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):
except DataError:
# On arrive ici si moduleimpl_id == "autre" ou moduleimpl_id == <str> non parsé
if moduleimpl_id != "autre":
raise ScoValueError("Module non reconnu")
# Configuration de external_data pour Module Autre
# Si self.external_data None alors on créé un dictionnaire {"module": "autre"}
# Sinon on met à jour external_data["module"] à "autre"
if self.external_data is None:
self.external_data = {"module": moduleimpl_id}
self.external_data = {"module": "autre"}
else:
self.external_data["module"] = moduleimpl_id
self.external_data["module"] = "autre"
# Dans tous les cas une fois fait, assiduite.moduleimpl_id doit être None
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=self.etudiant.dept_id)
if force:
raise ScoValueError("Module non renseigné")
return True
# Ici pas de vérification du force module car on l'a mis dans "external_data"
def supprime(self):
"Supprime l'assiduité. Log et commit."
@ -351,6 +360,27 @@ class Assiduite(ScoDocModel):
return f"saisie le {date} {utilisateur}"
def _check_force_module(self, moduleimpl: ModuleImpl) -> bool:
# 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=self.etudiant.dept_id)
if force:
raise ScoValueError("Module non renseigné")
return True
class Justificatif(ScoDocModel):
"""
@ -588,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

@ -394,6 +394,10 @@ def get_assiduites_stats(
if "etat" in filtered
else ["absent", "present", "retard"]
)
# être sur que les états sont corrects
etats = [etat for etat in etats if etat in ["absent", "present", "retard"]]
# Préparation du dictionnaire de retour avec les valeurs du calcul
count: dict = calculator.to_dict(only_total=False)
for etat in etats:
@ -684,6 +688,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 = (
@ -752,3 +757,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

@ -121,6 +121,33 @@ class ScoDocCache:
for oid in oids:
cls.delete(oid)
@classmethod
def delete_pattern(cls, pattern: str, std_prefix=True) -> int:
"""Delete all keys matching pattern.
The pattern starts with flask_cache_<dept_acronym>.
If std_prefix is true (default), the prefix is added
to the given pattern.
Examples:
'TABASSI_tableau-etud-1234:*'
Or, with std_prefix false, 'flask_cache_RT_TABASSI_tableau-etud-1234:*'
Returns number of keys deleted.
"""
# see https://stackoverflow.com/questions/36708461/flask-cache-list-keys-based-on-a-pattern
assert CACHE.cache.__class__.__name__ == "RedisCache" # Redis specific
import redis
if std_prefix:
pattern = "flask_cache_" + g.scodoc_dept + "_" + cls.prefix + "_" + pattern
r = redis.Redis()
count = 0
for key in r.scan_iter(pattern):
log(f"{cls.__name__}.delete_pattern({key})")
r.delete(key)
count += 1
return count
class EvaluationCache(ScoDocCache):
"""Cache for evaluations.
@ -369,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

@ -149,7 +149,9 @@ get_maquette_url = _PI.get_maquette_url
get_portal_api_version = _PI.get_portal_api_version
def get_inscrits_etape(code_etape, annee_apogee=None, ntrials=4, use_cache=True):
def get_inscrits_etape(
code_etape, annee_apogee=None, ntrials=4, use_cache=True
) -> list[dict]:
"""Liste des inscrits à une étape Apogée
Result = list of dicts
ntrials: try several time the same request, useful for some bad web services

View File

@ -132,7 +132,7 @@ def formsemestre_synchro_etuds(
if isinstance(etuds, str):
etuds = etuds.split(",") # vient du form de confirmation
elif isinstance(etuds, int):
etuds = [etuds]
etuds = [str(etuds)]
if isinstance(inscrits_without_key, int):
inscrits_without_key = [inscrits_without_key]
elif isinstance(inscrits_without_key, str):

View File

@ -110,11 +110,12 @@ function validateSelectors(btn) {
getAssiduitesFromEtuds(true);
document.querySelector(".selectors").disabled = true;
$("#tl_date").datepicker("option", "disabled", true);
// document.querySelector(".selectors").disabled = true;
// $("#tl_date").datepicker("option", "disabled", true);
generateMassAssiduites();
generateAllEtudRow();
btn.remove();
// btn.remove();
btn.textContent = "Actualiser";
onlyAbs();
};
@ -533,6 +534,7 @@ function massAction() {
* puis on ajoute les événements associés
*/
function generateMassAssiduites() {
if (readOnly || document.querySelector(".mass-selection") != null) return;
const content = document.getElementById("content");
const mass = document.createElement("div");
@ -1411,7 +1413,8 @@ function generateEtudRow(
assi += `<input type="checkbox" value="${abs}" name="btn_assiduites_${index}" id="rbtn_${abs}" class="rbtn ${abs}" title="${abs}">`;
}
});
const conflit = assiduite.type == "conflit" ? "conflit" : "";
if (readOnly) assi = "";
const conflit = assiduite.type == "conflit" && !readOnly ? "conflit" : "";
const pdp_url = `${getUrl()}/api/etudiant/etudid/${etud.id}/photo?size=small`;
let defdem = "";
@ -1543,11 +1546,11 @@ function generateAllEtudRow() {
return;
}
if (!document.querySelector(".selectors")?.disabled) {
return;
}
document.querySelector(".etud_holder").innerHTML = "";
// if (!document.querySelector(".selectors")?.disabled) {
// return;
// }
const etud_hodler = document.querySelector(".etud_holder");
if (etud_hodler) etud_hodler.innerHTML = "";
etuds_ids = Object.keys(etuds).sort((a, b) =>
etuds[a].nom > etuds[b].nom ? 1 : etuds[b].nom > etuds[a].nom ? -1 : 0
);

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

@ -1,741 +0,0 @@
{% block pageContent %}
{% include "assiduites/widgets/alert.j2" %}
<div class="pageContent">
{{minitimeline | safe }}
<h2>Assiduité de {{sco.etud.html_link_fiche()|safe}}</h2>
<div class="options">
<input type="checkbox" id="show_pres" name="show_pres" class="memo"><label for="show_pres">afficher les présences</label>
<input type="checkbox" name="show_reta" id="show_reta" class="memo"><label for="show_reta">afficher les retards</label>
<input type="checkbox" name="mode_demi" id="mode_demi" class="memo" checked><label for="mode_demi">mode demi journée</label>
</div>
<div class="calendrier">
</div>
<div class="annee">
<span id="label-annee">Année scolaire 2022-2023</span><span id="label-changer" style="margin-left: 5px;">Changer
année: </span>
<select name="" id="annee" onchange="setterAnnee(this.value)">
</select>
<span id="label-nom">Assiduité de {{sco.etud.nomprenom}}</span>
</div>
<div class="help">
<h3>Calendrier</h3>
<p>Code couleur</p>
<ul class="couleurs">
<li><span title="Vert" class="present demo"></span> &rightarrow; présence de l'étudiant lors de la période
</li>
<li><span title="Bleu clair" class="nonwork demo"></span> &rightarrow; la période n'est pas travaillée
</li>
<li><span title="Rouge" class="absent demo"></span> &rightarrow; absence de l'étudiant lors de la période
</li>
<li><span title="Rose" class="demo color absent est_just"></span> &rightarrow; absence justifiée
</li>
<li><span title="Orange" class="retard demo"></span> &rightarrow; retard de l'étudiant lors de la période
</li>
<li><span title="Jaune clair" class="demo color retard est_just"></span> &rightarrow; retard justifié
</li>
<li><span title="Quart Bleu" class="est_just demo"></span> &rightarrow; la période est couverte par un
justificatif valide</li>
<li><span title="Justif. non valide" class="invalide demo"></span> &rightarrow; la période est
couverte par un justificatif non valide
</li>
<li><span title="Justif. en attente" class="attente demo"></span> &rightarrow; la période
a un justificatif en attente de validation
</li>
</ul>
<p>Vous pouvez passer le curseur sur les jours colorés afin de voir les informations supplémentaires</p>
</div>
<ul class="couleurs print">
<li><span title="Vert" class="present demo"></span> présence
</li>
<li><span title="Bleu clair" class="nonwork demo"></span> non travaillé
</li>
<li><span title="Rouge" class="absent demo"></span> absence
</li>
<li><span title="Rose" class="demo color absent est_just"></span> absence justifiée
</li>
<li><span title="Orange" class="retard demo"></span> retard
</li>
<li><span title="Jaune clair" class="demo color retard est_just"></span>retard justifié
</li>
<li><span title="Quart Bleu" class="est_just demo"></span>
justificatif valide</li>
<li><span title="Quart Violet" class="invalide demo"></span> justificatif non valide
</li>
</ul>
</div>
<style>
.help .couleurs {
grid-template-columns: 2;
grid-template-rows: auto;
display: grid;
}
.couleurs.print {
display: none;
}
.help .couleurs li:nth-child(odd) {
grid-column: 1;
list-style-type: none;
}
.help .couleurs li:nth-child(even) {
grid-column: 2;
list-style-type: none;
}
.color.present {
background-color: var(--color-present) !important;
}
.color.absent {
background-color: var(--color-absent) !important;
}
.color.absent.est_just {
background-color: var(--color-absent-justi) !important;
}
.color.retard {
background-color: var(--color-retard) !important;
}
.color.retard.est_just {
background-color: var(--color-retard-justi) !important;
}
.color.nonwork {
background-color: var(--color-nonwork) !important;
}
.color {
background-color: var(--color-defaut) !important;
}
.pageContent {
margin-top: 1vh;
max-width: var(--sco-content-max-width);
}
.calendrier {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
border: 1px solid #444;
border-radius: 12px;
margin-bottom: 12px;
}
.month h3 {
text-align: center;
}
.day,
.demi .day.color.nonwork {
text-align: left;
margin: 2px;
cursor: default;
font-size: 13px;
position: relative;
font-weight: normal;
min-width: 6em;
display: flex;
justify-content: start;
}
.color.est_just.sans_etat::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
background-color: var(--color-justi) !important;
right: 0;
}
.color.invalide::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
right: 0;
background-color: var(--color-justi-invalide) !important;
}
.color.attente::before, .color.modifie::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
right: 0;
background: repeating-linear-gradient(
to bottom,
var(--color-justi-attente-stripe) 0px,
var(--color-justi-attente-stripe) 4px,
var(--color-justi-attente) 4px,
var(--color-justi-attente) 7px
)!important;
}
.demo.invalide {
background-color: var(--color-justi-invalide) !important;
}
.demo.attente {
background: repeating-linear-gradient(
to bottom,
var(--color-justi-attente-stripe) 0px,
var(--color-justi-attente-stripe) 4px,
var(--color-justi-attente) 4px,
var(--color-justi-attente) 7px
)!important;
}
.demo.est_just {
background-color: var(--color-justi) !important;
}
.demi .day.nonwork>span {
flex: none;
border: none;
}
.demi .day {
border-radius: 0;
}
.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;
}
@media print {
.couleurs.print {
display: flex;
justify-content: space-evenly;
align-items: center;
}
.couleurs.print li {
list-style-type: none !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.day,
.demi .day.color.color.nonwork {
min-width: 5em;
font-size: 11px;
}
.demi .day>span:first-of-type {
width: 2.5em;
min-width: 2.5em;
}
.color {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.day.est_just,
.demi .day span.est_just {
background-image: none;
}
.day.invalide,
.demi .day span.invalide {
background-image: none;
}
.demi .day span.est_just::before {
content: "J";
}
.demi .day span.invalide::before {
content: "JI";
}
#sidebar,
.help,
h2,
#annee,
#label-changer,
.options {
display: none;
}
#label-nom,
#label-justi {
display: inline;
}
#gtrcontent {
margin: 5px;
}
.annee {
display: flex;
justify-content: space-evenly;
align-items: center;
}
}
</style>
<script>
const datePivot = "{{scu.get_assiduites_time_config("pivot")}}".split(":").map((el) => Number(el))
function getDaysBetweenDates(start, end) {
let now = new Date(start);
end = new Date(end);
let dates = [];
while (now.isBefore(end) || now.isSame(end)) {
dates.push(now.clone());
now.add(1, "days");
}
return dates;
}
function organizeByMonth(dates) {
let datesByMonth = {};
dates.forEach((date) => {
let month = date.toLocaleString('fr-FR', { month: "short" }); // Obtenir le mois
if (!datesByMonth[month]) {
datesByMonth[month] = [];
}
datesByMonth[month].push(date);
});
return datesByMonth;
}
function organizeAssiduitiesByDay(datesByMonth, assiduities, justificatifs) {
let assiduitiesByDay = {};
Object.keys(datesByMonth).forEach((month) => {
assiduitiesByDay[month] = {};
datesByMonth[month].forEach((date) => {
let dayAssiduities = assiduities.filter((assiduity) => {
return new Date(date).isBetween(
new Date(assiduity.date_debut).startOf("day"),
new Date(assiduity.date_fin).endOf("day"),
"[]"
)
});
let dayJustificatifs = justificatifs.filter((justif) => {
return new Date(date).isBetween(
new Date(justif.date_debut).startOf("day"),
new Date(justif.date_fin).endOf("day"),
"[]"
)
});
assiduitiesByDay[month][date.toLocaleDateString("en-US")] = {
assiduites: dayAssiduities,
justificatifs: dayJustificatifs
};
});
});
return assiduitiesByDay;
}
function generateCalendar(assiduitiesByDay, nonWorkdays = []) {
// assiduitiesByDay[month][date] avec date au format m/d/y !!!
const calendar = document.querySelector('.calendrier')
const options = getOptions();
calendar.innerHTML = ""
const days = {
1: "Lun",
2: "Mar",
3: "Mer",
4: "Jeu",
5: "Ven",
6: "Sam",
0: "Dim",
};
// XXX formats de données très exotiques !
// XXX assiduitiesByDay["oct."]["10/12/2023"]
// XXX Object { assiduites: [], justificatifs: [] }
Object.keys(assiduitiesByDay).forEach((month) => {
const monthEl = document.createElement('div')
monthEl.classList.add("month")
const title = document.createElement('h3');
title.textContent = `${month.capitalize()}`;
monthEl.appendChild(title)
const daysEl = document.createElement('div')
daysEl.classList.add('days');
if (options.mode_demi) daysEl.classList.add("demi");
Object.keys(assiduitiesByDay[month]).forEach((date) => {
let dayAssiduities = assiduitiesByDay[month][date].assiduites;
let dayJustificatifs = assiduitiesByDay[month][date].justificatifs;
let color = "sans_etat";
if (dayAssiduities.some((a) => a.etat.toLowerCase() === "absent")) color = "absent";
else if (dayAssiduities.some((a) => a.etat.toLowerCase() === "retard") && options.show_reta)
color = "retard";
else if (dayAssiduities.some((a) => a.etat.toLowerCase() === "present") && options.show_pres)
color = "present";
let est_just = ""
if (dayJustificatifs.some((j) => j.etat.toLowerCase() === "valide")) {
est_just = "est_just";
} else if (dayJustificatifs.some((j) => j.etat.toLowerCase() === "attente")) {
est_just = "attente";
} else if (dayJustificatifs.some((j) => j.etat.toLowerCase() === "modifie")) {
est_just = "modifie";
} else if (dayJustificatifs.some((j) => j.etat.toLowerCase() !== "valide")) {
est_just = "invalide";
}
const momentDate = new Date(date);
let dayOfMonth = momentDate.getDate();
let dayOfWeek = momentDate.getDay();
dayOfWeek = days[dayOfWeek];
let isNonWorkDayVar = nonWorkdays.includes(dayOfWeek.toLowerCase());
const day = document.createElement('div');
day.className = `day`;
if (isNonWorkDayVar) {
color = "nonwork";
} else if (!options.mode_demi) {
day.className = `day ${est_just}`;
}
if (options.mode_demi && !isNonWorkDayVar) {
est_just = []
// affichage n° jour + matin + aprem
const span_jour = document.createElement("span")
span_jour.textContent = dayOfWeek[0] + dayOfMonth;
const span_matin = document.createElement("span");
span_matin.classList.add("color");
const matin = [new Date(date), new Date(date)]
color = "sans_etat"
matin[0].setHours(0, 0, 0, 0)
matin[1].setHours(...datePivot)
const assiduitesMatin = dayAssiduities.filter((el) => {
const deb = new Date(el.date_debut);
const fin = new Date(el.date_fin);
return Date.intersect({ deb: deb, fin: fin }, { deb: matin[0], fin: matin[1] })
})
const justificatifsMatin = dayJustificatifs.filter((el) => {
const deb = new Date(el.date_debut);
const fin = new Date(el.date_fin);
return Date.intersect({ deb: deb, fin: fin }, { deb: matin[0], fin: matin[1] })
})
if (assiduitesMatin.some((a) => a.etat.toLowerCase() === "absent")) color = "absent";
else if (assiduitesMatin.some((a) => a.etat.toLowerCase() === "retard") && options.show_reta)
color = "retard";
else if (assiduitesMatin.some((a) => a.etat.toLowerCase() === "present") && options.show_pres)
color = "present";
if (color != "") {
span_matin.classList.add(color);
}
if (justificatifsMatin.some((j) => j.etat.toLowerCase() === "valide")) {
est_just = ["est_just"];
} else if (justificatifsMatin.some((j) => j.etat.toLowerCase() === "attente")) {
est_just = ["attente"];
} else if (justificatifsMatin.some((j) => j.etat.toLowerCase() === "modifie")) {
est_just = ["modifie"];
}
else if (justificatifsMatin.some((j) => j.etat.toLowerCase() !== "valide")) {
est_just = ["invalide"];
}
span_matin.classList.add(...est_just)
est_just = []
const span_aprem = document.createElement("span");
span_aprem.classList.add("color");
const aprem = [new Date(date), new Date(date)]
color = "sans_etat"
aprem[0].setHours(...datePivot)
aprem[0].add(1, "seconds")
aprem[1].setHours(23, 59, 59)
const assiduitesAprem = dayAssiduities.filter((el) => {
const deb = new Date(el.date_debut);
const fin = new Date(el.date_fin);
return Date.intersect({ deb: deb, fin: fin }, { deb: aprem[0], fin: aprem[1] })
})
const justificatifsAprem = dayJustificatifs.filter((el) => {
const deb = new Date(el.date_debut);
const fin = new Date(el.date_fin);
return Date.intersect({ deb: deb, fin: fin }, { deb: aprem[0], fin: aprem[1] })
})
if (assiduitesAprem.some((a) => a.etat.toLowerCase() === "absent")) color = "absent";
else if (assiduitesAprem.some((a) => a.etat.toLowerCase() === "retard") && options.show_reta)
color = "retard";
else if (assiduitesAprem.some((a) => a.etat.toLowerCase() === "present") && options.show_pres)
color = "present";
if (color != "") {
span_aprem.classList.add(color);
}
if (justificatifsAprem.some((j) => j.etat.toLowerCase() === "valide")) {
est_just = ["est_just"];
} else if (justificatifsAprem.some((j) => j.etat.toLowerCase() === "attente")) {
est_just = ["attente"];
} else if (justificatifsAprem.some((j) => j.etat.toLowerCase() === "modifie")) {
est_just = ["modifie"];
} else if (justificatifsAprem.some((j) => j.etat.toLowerCase() !== "valide")) {
est_just = ["invalide"];
}
span_aprem.classList.add(...est_just)
day.appendChild(span_jour)
day.appendChild(span_matin)
day.appendChild(span_aprem)
} else {
day.classList.add("color")
if (color != "") {
day.classList.add(color);
}
if (isNonWorkDayVar) {
const span_jour = document.createElement("span")
span_jour.textContent = dayOfWeek[0] + dayOfMonth;
day.appendChild(span_jour);
} else {
day.textContent = `${dayOfWeek} ${dayOfMonth}`;
}
}
if (!nonWorkdays.includes(dayOfWeek.toLowerCase()) && dayAssiduities.length > 0) {
const cache = document.createElement('div')
cache.classList.add('dayline');
const title = document.createElement('div')
title.className = "dayline-title";
title.innerHTML = "<span>Assiduité du </span><br>" + `<span>${formatDate(momentDate)}</span>`;
cache.appendChild(title)
cache.appendChild(
createMiniTimeline(dayAssiduities, date)
)
day.appendChild(cache)
}
daysEl.appendChild(day);
});
monthEl.appendChild(daysEl)
calendar.appendChild(monthEl)
});
}
function getEtudAssiduites(deb, fin, callback = () => { }) {
const url_api =
getUrl() +
`/api/assiduites/${etudid}/query?date_debut=${deb}&date_fin=${fin}`;
async_get(url_api, (data) => {
callback(data);
});
}
function getOptions() {
return {
"show_pres": document.getElementById("show_pres").checked,
"show_reta": document.getElementById("show_reta").checked,
"mode_demi": document.getElementById("mode_demi").checked,
}
}
function getEtudJustificatifs(deb, fin) {
let list = [];
const url_api =
getUrl() +
`/api/justificatifs/${etudid}/query?date_debut=${deb}&date_fin=${fin}`;
sync_get(url_api, (data, status) => {
if (status === "success") {
list = data;
}
});
return list
}
function generate(annee) {
if (annee < 1999 || annee > 2999) {
openAlertModal("Année impossible", document.createTextNode("L'année demandé n'existe pas."));
return;
}
const bornes = {
deb: `${annee}-09-01T00:00`,
fin: `${annee + 1}-08-31T23:59`
}
let assiduities = getEtudAssiduites(bornes.deb, bornes.fin, (data) => {
let dates = getDaysBetweenDates(bornes.deb, bornes.fin);
let datesByMonth = organizeByMonth(dates);
const justifs = getEtudJustificatifs(bornes.deb, bornes.fin);
let assiduitiesByDay = organizeAssiduitiesByDay(datesByMonth, data, justifs);
generateCalendar(assiduitiesByDay, nonwork);
});
}
function setterAnnee(annee) {
annee = parseInt(annee);
document.querySelector('.annee #label-annee').textContent = `Année scolaire ${annee}-${annee + 1}`
generate(annee)
}
const defAnnee = {{ annee }}
let annees = {{ annees | safe }}
annees = annees.filter((x, i) => annees.indexOf(x) === i)
const etudid = {{ sco.etud.id }};
const nonwork = [{{ nonworkdays | safe }}];
window.onload = () => {
const select = document.querySelector('#annee');
annees.forEach((a) => {
const opt = document.createElement("option");
opt.value = a + "",
opt.textContent = `${a} - ${a + 1}`;
if (a === defAnnee) {
opt.selected = true;
}
select.appendChild(opt)
})
document.querySelectorAll(".options input").forEach((e) => {
e.addEventListener("click", () => {
setterAnnee(select.value)
})
})
setterAnnee(defAnnee)
};
function isCalendrier() { return true }
/* --- Mémorisation des checkbox ---- */
document.querySelectorAll('input[type="checkbox"].memo').forEach(checkbox => {
checkbox.addEventListener('change', function() {
localStorage.setItem(this.id, this.checked);
});
// Load the saved state
document.querySelectorAll('input[type="checkbox"].memo').forEach(checkbox => {
const checked = localStorage.getItem(checkbox.id) === 'true';
checkbox.checked = checked;
});
});
</script>
{% endblock pageContent %}

View File

@ -0,0 +1,30 @@
{% extends "sco_page.j2" %}
{% import 'wtf.j2' as wtf %}
{% block styles %}
{{super()}}
<link rel="stylesheet" href="{{scu.STATIC_DIR}}/libjs/timepicker-1.3.5/jquery.timepicker.min.css"/>
{% endblock %}
{% block app_content %}
{% for err_msg in form.error_messages %}
<div class="wtf-error-messages">
{{ err_msg }}
</div>
{% endfor %}
<h2>La date courante n'est pas dans le semestre ({{deb}} -> {{fin}})</h2>
<h2>Choissez une autre date</h2>
<form action="" method="post">
{{ form.hidden_tag() }}
{{ form.date.label }}&nbsp;: {{ form.date }}
<div class="submit">
{{ form.submit }} {{ form.cancel }}
</div>
</form>
{% endblock app_content %}
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/libjs/timepicker-1.3.5/jquery.timepicker.min.js"></script>
{% endblock scripts %}

View File

@ -102,6 +102,9 @@
setupTimeLine(() => {
if(document.querySelector('.etud_holder .placeholder') != null){
generateAllEtudRow();
}
updateJustifyBtn();
});

View File

@ -26,11 +26,9 @@
</div>
</fieldset>
{% if readonly == "false" %}
{{timeline|safe}}
{% if readonly == "false" %}
<div style="margin: 1vh 0;">
<div id="forcemodule" style="display: none; margin:10px 0px;">
Vous devez spécifier le module ! (voir réglage préférence du semestre)
@ -40,7 +38,6 @@
{% else %}
{% endif %}
{% if readonly == "true" %}
<button id="validate_selectors" onclick="validateSelectors(this)">
Voir l'assiduité
@ -50,11 +47,11 @@
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">
<p class="placeholder">
Veillez à choisir le groupe concerné et la date.
Après validation, il faudra recharger la page pour changer ces informations.
</p>
</div>
<div class="legende">
@ -84,6 +81,13 @@
<script>
{% if readonly != "false" %}
function getPeriodValues(){
return [0, 23]
}
{% endif %}
const nonWorkDays = [{{ nonworkdays| safe }}];
const readOnly = {{ readonly }};
@ -91,7 +95,13 @@
setupDate();
updateDate();
setupTimeLine();
if (!readOnly){
setupTimeLine(()=>{
if(document.querySelector('.etud_holder .placeholder') != null){
generateAllEtudRow();
}
});
}
window.forceModule = "{{ forcer_module }}"
window.forceModule = window.forceModule == "True" ? true : false

View File

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test DateUtils</title>
<script src="{{scu.STATIC_DIR}}/js/date_utils.js"></script>
</head>
<body>
<form action="" method="post">
<scodoc-datetime name="scodoc-datetime"></scodoc-datetime>
<input type="submit" value="valider">
</form>
</body>
</html>

View File

@ -39,6 +39,7 @@
}
array.forEach((assiduité) => {
if(assiduité.etat == "CRENEAU" && readOnly) return;
let startDate = new Date(Date.removeUTC(assiduité.date_debut));
let endDate = new Date(Date.removeUTC(assiduité.date_fin));
if (startDate.isBefore(dayStart)) {
@ -284,7 +285,7 @@
text-align: start;
top: -40px;
transform: translateX(-50%);
z-index: 50;
z-index: 1;
}

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

@ -119,8 +119,6 @@
};
const mouseUp = () => {
snapHandlesToQuarters();
generateAllEtudRow();
timelineContainer.removeEventListener("mousemove", onMouseMove);
handleMoving = false;
func_call();
@ -264,6 +262,7 @@
}
createTicks();
setPeriodValues(t_start, t_start + period_default);
{% if heures %}
@ -271,6 +270,7 @@
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

@ -43,6 +43,7 @@ from app.forms.assiduite.ajout_assiduite_etud import (
AjoutAssiOrJustForm,
AjoutAssiduiteEtudForm,
AjoutJustificatifEtudForm,
ChoixDateForm,
)
from app.models import (
Assiduite,
@ -171,7 +172,7 @@ class HTMLBuilder:
@bp.route("/")
@bp.route("/BilanDept")
@bp.route("/bilan_dept")
@scodoc
@permission_required(Permission.AbsChange)
def bilan_dept():
@ -323,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
@ -523,10 +525,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]
@ -696,6 +699,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
@ -878,7 +882,7 @@ def calendrier_assi_etud():
return HTMLBuilder(
header,
render_template(
"assiduites/pages/calendrier2.j2",
"assiduites/pages/calendrier_assi_etud.j2",
sco=ScoData(etud),
annee=annee,
nonworkdays=_non_work_days(),
@ -891,6 +895,56 @@ def calendrier_assi_etud():
).build()
@bp.route("/choix_date", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def choix_date() -> str:
formsemestre_id = request.args.get("formsemestre_id")
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
group_ids = request.args.get("group_ids")
moduleimpl_id = request.args.get("moduleimpl_id")
form = ChoixDateForm(request.form)
if form.validate_on_submit():
if form.cancel.data:
return redirect(url_for("scodoc.index"))
# Vérifier si date dans semestre
ok: bool = False
try:
date: datetime.date = datetime.datetime.strptime(
form.date.data, "%d/%m/%Y"
).date()
if date < formsemestre.date_debut or date > formsemestre.date_fin:
form.set_error(
"La date sélectionnée n'est pas dans le semestre.", form.date
)
else:
ok = True
except ValueError:
form.set_error("Date invalide", form.date)
if ok:
return redirect(
url_for(
"assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
group_ids=group_ids,
moduleimpl_id=moduleimpl_id,
jour=date.isoformat(),
)
)
return render_template(
"assiduites/pages/choix_date.j2",
form=form,
sco=ScoData(formsemestre=formsemestre),
deb=formsemestre.date_debut.strftime("%d/%m/%Y"),
fin=formsemestre.date_fin.strftime("%d/%m/%Y"),
)
@bp.route("/signal_assiduites_group")
@scodoc
@permission_required(Permission.AbsChange)
@ -962,15 +1016,15 @@ def signal_assiduites_group():
real_date = scu.is_iso_formated(date, True).date()
if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
# Si le jour est hors semestre, indiquer une erreur
# Formatage des dates pour le message d'erreur
real_str = real_date.strftime("%d/%m/%Y")
form_deb = formsemestre.date_debut.strftime("%d/%m/%Y")
form_fin = formsemestre.date_fin.strftime("%d/%m/%Y")
raise ScoValueError(
f"Impossible de saisir l'assiduité pour le {real_str}"
+ f" : Jour en dehors du semestre ( {form_deb}{form_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,
)
)
# --- Restriction en fonction du moduleimpl_id ---
@ -1051,7 +1105,7 @@ def signal_assiduites_group():
).build()
@bp.route("/VisuAssiduiteGr")
@bp.route("/visu_assiduites_group")
@scodoc
@permission_required(Permission.ScoView)
def visu_assiduites_group():
@ -1391,6 +1445,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
@ -1427,6 +1482,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()
@ -1437,12 +1499,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"):
@ -1490,6 +1555,21 @@ def tableau_assiduite_actions():
flash(f"{objet_name} supprimé")
return redirect(request.referrer)
# Justification d'une assiduité depuis le tableau
if action == "justifier" and obj_type == "assiduite":
# Création du justificatif correspondant
justificatif_correspondant: Justificatif = Justificatif.create_justificatif(
etudiant=objet.etudiant,
date_debut=objet.date_debut,
date_fin=objet.date_fin,
etat=scu.EtatJustificatif.VALIDE,
user_id=current_user.id,
)
compute_assiduites_justified(objet.etudiant.id, [justificatif_correspondant])
flash(f"{objet_name} justifiée")
return redirect(request.referrer)
# Justification d'une assiduité depuis le tableau
if action == "justifier" and obj_type == "assiduite":
@ -1570,20 +1650,10 @@ def _action_modifier_assiduite(assi: Assiduite):
# Gestion de la description
assi.description = form["description"]
module: str = form["moduleimpl_select"]
possible_moduleimpl_id: str = form["moduleimpl_select"]
if module == "":
module = None
else:
try:
module = int(module)
except ValueError:
pass
# TODO revoir, documenter (voir set_moduleimpl)
# ne pas appeler module ici un paramètre qui s'appelle moduleimpl_id dans la fonction
# module == instance de Module
# moduleimpl_id : id, toujours integer
assi.set_moduleimpl(module)
# Raise ScoValueError (si None et force module | Etudiant non inscrit | Module non reconnu)
assi.set_moduleimpl(possible_moduleimpl_id)
db.session.add(assi)
db.session.commit()
@ -1989,16 +2059,6 @@ def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
)
@bp.route("/testDate", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
def testDateutils():
"""XXX fonction de test a retirer"""
if request.method == "POST":
print("test date_utils : ", request.form)
return render_template("assiduites/pages/test.j2")
# --- Fonctions internes ---

View File

@ -1,7 +1,7 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
SCOVERSION = "9.6.77"
SCOVERSION = "9.6.78"
SCONAME = "ScoDoc"

View File

@ -48,6 +48,10 @@ def test_notes_table(test_client): # XXX A REVOIR POUR TESTER RES TODO
formsemestre_id = sem["formsemestre_id"]
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
assert sco_cache.ResultatsSemestreCache.get(formsemestre_id)
# Efface les semestres
sco_cache.ResultatsSemestreCache.delete_pattern("*")
for sem in sems[:10]:
assert sco_cache.ResultatsSemestreCache.get(formsemestre_id) is None
def test_cache_evaluations(test_client):

View File

@ -4,7 +4,7 @@
emulating "Apogee" Web service
Usage:
/opt/scodoc/tools/fakeportal/fakeportal.py
/opt/scodoc/tools/fakeportal/fakeportal.py
et régler "URL du portail" sur la page de *Paramétrage* du département testé,
typiquement: http://localhost:8678