Assiduites : WIP todos

This commit is contained in:
Iziram 2024-01-18 17:05:43 +01:00
parent 44de81857a
commit 7659bcb488
13 changed files with 257 additions and 215 deletions

View File

@ -34,52 +34,11 @@ import re
from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError
from wtforms.fields.simple import StringField
from wtforms.validators import Optional
from wtforms.validators import Optional, Length
from wtforms.widgets import TimeInput
class TimeField(StringField):
"""HTML5 time input.
tiré de : https://gist.github.com/tachyondecay/6016d32f65a996d0d94f
"""
widget = TimeInput()
def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs):
super(TimeField, self).__init__(label, validators, **kwargs)
self.fmt = fmt
self.data = None
def _value(self):
if self.raw_data:
return " ".join(self.raw_data)
if self.data and isinstance(self.data, str):
self.data = datetime.time(*map(int, self.data.split(":")))
return self.data and self.data.strftime(self.fmt) or ""
def process_formdata(self, valuelist):
if valuelist:
time_str = " ".join(valuelist)
try:
components = time_str.split(":")
hour = 0
minutes = 0
seconds = 0
if len(components) in range(2, 4):
hour = int(components[0])
minutes = int(components[1])
if len(components) == 3:
seconds = int(components[2])
else:
raise ValueError
self.data = datetime.time(hour, minutes, seconds)
except ValueError as exc:
self.data = None
raise ValueError(self.gettext("Not a valid time string")) from exc
def check_tick_time(form, field):
"""Le tick_time doit être entre 0 et 60 minutes"""
if field.data < 1 or field.data > 59:
@ -118,14 +77,36 @@ def check_ics_regexp(form, field):
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité"
assi_morning_time = TimeField(
"Début de la journée"
) # TODO utiliser TextField + timepicker voir AjoutAssiOrJustForm
assi_lunch_time = TimeField(
"Heure de midi (date pivot entre matin et après-midi)"
) # TODO
assi_afternoon_time = TimeField("Fin de la journée") # TODO
assi_morning_time = StringField(
"Début de la journée",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_morning_time",
},
)
assi_lunch_time = StringField(
"Heure de midi (date pivot entre matin et après-midi)",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_lunch_time",
},
)
assi_afternoon_time = StringField(
"Fin de la journée",
validators=[Length(max=5)],
default="",
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_afternoon_time",
},
)
assi_tick_time = DecimalField(
"Granularité de la timeline (temps en minutes)",

View File

@ -17,7 +17,15 @@ from app import log
class Trace:
"""gestionnaire de la trace des fichiers justificatifs
XXX TODO à documenter: rôle et format des fichier strace
Role des fichiers traces :
- Sauvegarder la date de dépot du fichier
- Sauvegarder la date de suppression du fichier (dans le cas de plusieurs fichiers pour un même justif)
- Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier (=> permet de montrer les fichiers qu'aux personnes qui l'on déposé / qui ont le rôle AssiJustifView)
_trace.csv :
nom_fichier_srv,datetime_depot,datetime_suppr,user_id
"""
def __init__(self, path: str) -> None:
@ -39,7 +47,7 @@ class Trace:
continue
entry_date: datetime = is_iso_formated(csv[1], True)
delete_date: datetime = is_iso_formated(csv[2], True)
user_id = csv[3]
user_id = csv[3].strip()
self.content[fname] = [entry_date, delete_date, user_id]
if os.path.isfile(self.path):
@ -84,7 +92,14 @@ class Trace:
self, fnames: list[str] = None
) -> dict[str, list[datetime, datetime, str]]:
"""Récupère la trace pour les noms de fichiers.
si aucun nom n'est donné, récupère tous les fichiers"""
si aucun nom n'est donné, récupère tous les fichiers
retour :
{
"nom_fichier_srv": [datetime_depot, datetime_suppr/None, user_id],
...
}
"""
if fnames is None:
return self.content
@ -215,8 +230,7 @@ class JustificatifArchiver(BaseArchiver):
filenames = self.list_archive(archive_id, dept_id=etud.dept_id)
trace: Trace = Trace(archive_id)
traced = trace.get_trace(filenames)
return [(key, value[2]) for key, value in traced.items()]
return [(key, value[2]) for key, value in traced.items() if value is not None]
def get_justificatif_file(self, archive_name: str, etud: Identite, filename: str):
"""

View File

@ -450,8 +450,6 @@ def filter_by_date(
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb) # TODO A modifier (timezone ?)
date_fin = scu.localize_datetime(date_fin)
if not strict:
return collection.filter(
collection_cls.date_debut <= date_fin, collection_cls.date_fin >= date_deb
@ -558,15 +556,19 @@ def get_all_justified(
return after
def create_absence(
def create_absence_billet(
date_debut: datetime,
date_fin: datetime,
etudid: int,
description: str = None,
est_just: bool = False,
) -> int:
"""TODO: doc, dire quand l'utiliser"""
# TODO
"""
Permet de rapidement créer une absence.
**UTILISÉ UNIQUEMENT POUR LES BILLETS**
Ne pas utiliser autre par.
TALK: Vérifier si nécessaire
"""
etud: Identite = Identite.query.filter_by(etudid=etudid).first_or_404()
assiduite_unique: Assiduite = Assiduite.create_assiduite(
etud=etud,
@ -648,8 +650,7 @@ def get_assiduites_count_in_interval(
"""
date_debut_iso = date_debut_iso or date_debut.isoformat()
date_fin_iso = date_fin_iso or date_fin.isoformat()
# TODO Question: pourquoi ne pas cacher toutes les métriques, si l'API les veut toutes ?
key = f"{etudid}_{date_debut_iso}_{date_fin_iso}{metrique}_assiduites"
key = f"{etudid}_{date_debut_iso}_{date_fin_iso}_assiduites"
r = sco_cache.AbsSemEtudCache.get(key)
if not r or moduleimpl_id is not None:
@ -666,24 +667,24 @@ def get_assiduites_count_in_interval(
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites(assiduites)
calcul: dict = calculator.to_dict(only_total=False)
nb_abs: dict = calcul["absent"][metrique]
nb_abs_just: dict = calcul["absent_just"][metrique]
r = (nb_abs, nb_abs_just)
r = calcul
if moduleimpl_id is None:
ans = sco_cache.AbsSemEtudCache.set(key, r)
if not ans:
log("warning: get_assiduites_count failed to cache")
return r
nb_abs: dict = r["absent"][metrique]
nb_abs_just: dict = r["absent_just"][metrique]
return (nb_abs, nb_abs_just)
def invalidate_assiduites_count(etudid: int, sem: dict):
"""Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"]
date_fin = sem["date_fin_iso"]
for met in scu.AssiduitesMetrics.TAG:
key = str(etudid) + "_" + date_debut + "_" + date_fin + f"{met}_assiduites"
sco_cache.AbsSemEtudCache.delete(key)
key = str(etudid) + "_" + date_debut + "_" + date_fin + "_assiduites"
sco_cache.AbsSemEtudCache.delete(key)
# Non utilisé

View File

@ -299,8 +299,11 @@ def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or No
def localize_datetime(date: datetime.datetime or str) -> datetime.datetime:
"""Ajoute un timecode UTC à la date donnée.
XXX semble faire autre chose... TODO fix this comment
"""Transforme une date sans offset en une date avec offset
Tente de mettre l'offset de la timezone du serveur (ex : UTC+1)
Si erreur, mettra l'offset UTC
TODO : vérifier puis supprimer l'auto conversion str-> datetime
"""
if isinstance(date, str):
date = is_iso_formated(date, convert=True)

View File

@ -1282,19 +1282,14 @@ function getAllAssiduitesFromEtud(
.replace("°", courant ? "&courant" : "")
: ""
}`;
//TODO Utiliser async_get au lieu de jquery
$.ajax({
async: true,
type: "GET",
url: url_api,
success: (data, status) => {
if (status === "success") {
assiduites[etudid] = data;
action(data);
}
async_get(
url_api,
(data) => {
assiduites[etudid] = data;
action(data);
},
error: () => {},
});
(_) => {}
);
}
/**
@ -1864,18 +1859,13 @@ function getAllJustificatifsFromEtud(
order ? "/query?order°".replace("°", courant ? "&courant" : "") : ""
}`;
//TODO Utiliser async_get au lieu de jquery
$.ajax({
async: true,
type: "GET",
url: url_api,
success: (data, status) => {
if (status === "success") {
action(data);
}
async_get(
url_api,
(data) => {
action(data);
},
error: () => {},
});
() => {}
);
}
function deleteJustificatif(justif_id) {

View File

@ -129,41 +129,44 @@ class RowAssi(tb.Row):
)
def _get_etud_stats(self, etud: Identite) -> dict[str, list[str, float, float]]:
# XXX TODO @iziram commentaire sur la fonction et la var. retour
"""
Renvoie le comptage (dans la métrique du département) des différents états d'assiduité d'un étudiant
Returns :
{
"<etat>" : [<Etat version lisible>, <nb total etat>, <nb just etat>]
}
"""
# Préparation du retour
retour: dict[str, tuple[str, float, float]] = {
"absent": ["Absences", 0.0, 0.0],
"retard": ["Retards", 0.0, 0.0],
"present": ["Présences", 0.0, 0.0],
}
# Récupération de la métrique du département
assi_metric = scu.translate_assiduites_metric(
sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id),
)
compte_etat: dict[str, dict] = scass.get_assiduites_stats(
assiduites=etud.assiduites,
metric=assi_metric,
filtered={
"date_debut": self.dates[0],
"date_fin": self.dates[1],
"etat": "absent,present,retard", # pour tout compter d'un coup
"split": 1, # afin d'avoir la division des stats en état, etatjust, etatnonjust
},
)
# Pour chaque état on mets à jour les valeurs de retour
for etat, valeur in retour.items():
compte_etat = scass.get_assiduites_stats(
assiduites=etud.assiduites,
metric=assi_metric,
filtered={
"date_debut": self.dates[0],
"date_fin": self.dates[1],
"etat": etat,
},
)
compte_etat_just = scass.get_assiduites_stats(
assiduites=etud.assiduites,
metric=assi_metric,
filtered={
"date_debut": self.dates[0],
"date_fin": self.dates[1],
"etat": etat,
"est_just": True,
},
)
valeur[1] = compte_etat[assi_metric]
valeur[2] = compte_etat_just[assi_metric]
valeur[1] = compte_etat[etat][assi_metric]
if etat != "present":
valeur[2] = compte_etat[etat]["justifie"][assi_metric]
return retour

View File

@ -28,7 +28,7 @@
<!-- Tableaux des assiduités (retard/abs) non justifiées -->
<h4>Absences et retards non justifiés</h4>
{# XXX XXX XXX #}
{# TODO Utiliser python tableau plutot que js tableau #}
<div class="ue_warning">Attention, cette page utilise des couleurs et conventions différentes
de celles des autres pages ScoDoc: elle sera prochainement modifée, merci de votre patience.
</div>
@ -111,89 +111,76 @@
}
function getAssiduitesCount(dateDeb, dateFin, query) {
const url_api = getUrl() + `/api/assiduites/${etudid}/count/query?date_debut=${dateDeb}&date_fin=${dateFin}&${query}`;
function getAssiduitesCount(dateDeb, dateFin, action) {
const url_api = getUrl() + `/api/assiduites/${etudid}/count/query?date_debut=${dateDeb}&date_fin=${dateFin}&etat=absent,retard,present&split`;
//Utiliser async_get au lieu de Jquery
return $.ajax({
async: true,
type: "GET",
url: url_api,
success: (data, status) => {
if (status === "success") {
}
async_get(
url_api,
action,
()=>{},
);
}
function showStats(data){
const counter = {
"present": {
"total": data["present"],
},
error: () => { },
"retard": {
"total": data["retard"],
"justi": data["retard"]["justifie"],
},
"absent": {
"total": data["absent"],
"justi": data["absent"]["justifie"],
}
}
const values = document.querySelector('.stats-values');
values.innerHTML = "";
Object.keys(counter).forEach((key) => {
const item = document.createElement('div');
item.classList.add('stats-values-item');
const div = document.createElement('div');
div.classList.add('stats-values-part');
const withJusti = (key, metric) => {
if (key == "present") return "";
return ` dont ${counter[key].justi[metric]} justifiées`
}
const heure = document.createElement('span');
heure.textContent = `${counter[key].total.heure} heure(s)${withJusti(key, "heure")}`;
const demi = document.createElement('span');
demi.textContent = `${counter[key].total.demi} demi-journée(s)${withJusti(key, "demi")}`;
const jour = document.createElement('span');
jour.textContent = `${counter[key].total.journee} journée(s)${withJusti(key, "journee")}`;
div.append(jour, demi, heure);
const title = document.createElement('h5');
title.textContent = key.capitalize();
item.append(title, div)
values.appendChild(item);
});
const nbAbs = data["absent"]["non_justifie"][assi_metric];
if (nbAbs > assi_seuil) {
document.querySelector('.alerte').classList.remove('invisible');
document.querySelector('.alerte p').textContent = `Attention, cet étudiant a trop d'absences ${nbAbs} / ${assi_seuil} (${metriques[assi_metric]})`
} else {
document.querySelector('.alerte').classList.add('invisible');
}
}
function countAssiduites(dateDeb, dateFin) {
//TODO Utiliser Fetch when plutot que jquery
$.when(
getAssiduitesCount(dateDeb, dateFin, `etat=present`),
getAssiduitesCount(dateDeb, dateFin, `etat=retard`),
getAssiduitesCount(dateDeb, dateFin, `etat=retard&est_just=v`),
getAssiduitesCount(dateDeb, dateFin, `etat=absent`),
getAssiduitesCount(dateDeb, dateFin, `etat=absent&est_just=v`),
).then(
(pt, rt, rj, at, aj) => {
const counter = {
"present": {
"total": pt[0],
},
"retard": {
"total": rt[0],
"justi": rj[0],
},
"absent": {
"total": at[0],
"justi": aj[0],
}
}
const values = document.querySelector('.stats-values');
values.innerHTML = "";
Object.keys(counter).forEach((key) => {
const item = document.createElement('div');
item.classList.add('stats-values-item');
const div = document.createElement('div');
div.classList.add('stats-values-part');
const withJusti = (key, metric) => {
if (key == "present") return "";
return ` dont ${counter[key].justi[metric]} justifiées`
}
const heure = document.createElement('span');
heure.textContent = `${counter[key].total.heure} heure(s)${withJusti(key, "heure")}`;
const demi = document.createElement('span');
demi.textContent = `${counter[key].total.demi} demi-journée(s)${withJusti(key, "demi")}`;
const jour = document.createElement('span');
jour.textContent = `${counter[key].total.journee} journée(s)${withJusti(key, "journee")}`;
div.append(jour, demi, heure);
const title = document.createElement('h5');
title.textContent = key.capitalize();
item.append(title, div)
values.appendChild(item);
});
const nbAbs = counter.absent.total[assi_metric] - counter.absent.justi[assi_metric];
if (nbAbs > assi_seuil) {
document.querySelector('.alerte').classList.remove('invisible');
document.querySelector('.alerte p').textContent = `Attention, cet étudiant a trop d'absences ${nbAbs} / ${assi_seuil} (${metriques[assi_metric]})`
} else {
document.querySelector('.alerte').classList.add('invisible');
}
}
);
getAssiduitesCount(dateDeb, dateFin, showStats);
}
function removeAllAssiduites() {

View File

@ -3,6 +3,7 @@
{% block styles %}
{{super()}}
<link rel="stylesheet" href="{{scu.STATIC_DIR}}/libjs/timepicker-1.3.5/jquery.timepicker.min.css"/>
<style>
div.config-section {
font-weight: bold;
@ -31,8 +32,18 @@ div.config-section {
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/libjs/timepicker-1.3.5/jquery.timepicker.min.js"></script>
<script>
$('.timepicker').timepicker({
timeFormat: 'HH:mm',
interval: {{ scu.get_assiduites_time_config("assi_tick_time") }},
minTime: "00:00",
maxTime: "23:59",
dynamic: false,
dropdown: true,
scrollbar: false
});
function update_test_button_state() {
var inputValue = document.getElementById('test_edt_id').value;
document.getElementById('test_load_ics').disabled = inputValue.length === 0;
@ -78,10 +89,9 @@ c'est à dire à la montre des étudiants.
<div class="col-md-8">
{{ form.hidden_tag() }}
{{ wtf.form_errors(form, hiddens="only") }}
{{ wtf.form_field(form.assi_morning_time) }}
{{ wtf.form_field(form.assi_lunch_time) }}
{{ wtf.form_field(form.assi_afternoon_time) }}
{{ wtf.form_field(form.assi_morning_time, class="timepicker") }}
{{ wtf.form_field(form.assi_lunch_time, class="timepicker") }}
{{ wtf.form_field(form.assi_afternoon_time, class="timepicker") }}
{{ wtf.form_field(form.assi_tick_time) }}
</div>
</div>

View File

@ -6,8 +6,8 @@
*/
function getLeftPosition(start) {
const startTime = new Date(start);
const startMins = (startTime.getHours() - 8) * 60 + startTime.getMinutes();
return (startMins / (18 * 60 - 8 * 60)) * 100 + "%";
const startMins = (startTime.getHours() - t_start) * 60 + startTime.getMinutes();
return (startMins / (t_end * 60 - t_start * 60)) * 100 + "%";
}
/**
* Ajustement de l'espacement vertical entre les assiduités superposées
@ -76,7 +76,7 @@
const duration = (endTime - startTime) / 1000 / 60;
const percent = (duration / (18 * 60 - 8 * 60)) * 100
const percent = (duration / (t_end * 60 - t_start * 60)) * 100
if (percent > 100) {
console.log(start, end);
@ -162,6 +162,13 @@
document.querySelector('#myModal .close').addEventListener('click', () => { this.close() })
// fermeture du modal en appuyant sur echap
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.close()
}
}, { once: true })
this.render()
}
@ -246,6 +253,7 @@
*/
splitAssiduiteModal() {
//Préparation du prompt
// TODO utiliser timepicker jquery + utiliser les bornes (t_start et t_end)
const htmlPrompt = `<legend>Entrez l'heure de séparation (HH:mm) :</legend>
<input type="time" id="promptTime" name="appt"
min="08:00" max="18:00" required>`;
@ -371,8 +379,7 @@
assiduitesContainer.innerHTML = '<div class="assiduite-special"></div>';
// Ajout des labels d'heure sur la frise chronologique
// TODO permettre la modification des bornes (8 et 18)
for (let i = 8; i <= 18; i++) {
for (let i = t_start; i <= t_end; i++) {
const timeLabel = document.createElement("div");
timeLabel.className = "time-label";
timeLabel.textContent = i < 10 ? `0${i}:00` : `${i}:00`;

View File

@ -32,6 +32,55 @@
</div>
<div class="div-tableau">
<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>
{{table.html() | safe}}
<div class="options-tableau">
<!--Pagination basée sur : https://app.uxcel.com/courses/ui-components-best-practices/best-practices-005 -->

View File

@ -306,6 +306,7 @@ def _ProcessBilletAbsence(
return: nombre de demi-journées d'absence ajoutées, -1 si billet déjà traité.
NB: actuellement, les heures ne sont utilisées que pour déterminer
si matin et/ou après-midi.
TODO: Vérifier l'intégration avec le module Assiduité
"""
if billet.etat:
log(f"billet deja traite: {billet} !")
@ -316,7 +317,7 @@ def _ProcessBilletAbsence(
datedebut = billet.abs_begin
datefin = billet.abs_end
log(f"Gestion du billet n°{billet.id}")
n = scass.create_absence(
n = scass.create_absence_billet(
date_debut=datedebut,
date_fin=datefin,
etudid=billet.etudid,

View File

@ -1615,7 +1615,7 @@ def tableau_assiduite_actions():
return render_template(
"assiduites/pages/tableau_assiduite_actions.j2",
sco=ScoData(etud=objet.etudiant),
# XXX type semble être utilisé qq part, ne pas changer
# type utilisé dans les actions modifier / détails (modifier.j2, details.j2)
type="Justificatif" if obj_type == "justificatif" else "Assiduité",
action=action,
etud=objet.etudiant,
@ -1964,7 +1964,7 @@ def signale_evaluation_abs(etudid: int = None, evaluation_id: int = None):
evaluation_id=evaluation.id,
date_deb=evaluation.date_debut.strftime(
"%Y-%m-%dT%H:%M:%S"
), # XXX TODO
),
date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
moduleimpl_id=evaluation.moduleimpl.id,
saisie_eval="true",

View File

@ -358,14 +358,10 @@ def config_assiduites():
return redirect(url_for("scodoc.configuration"))
if request.method == "GET":
form.assi_morning_time.data = ScoDocSiteConfig.get(
"assi_morning_time", datetime.time(8, 0, 0)
)
form.assi_lunch_time.data = ScoDocSiteConfig.get(
"assi_lunch_time", datetime.time(13, 0, 0)
)
form.assi_morning_time.data = ScoDocSiteConfig.get("assi_morning_time", "08:00")
form.assi_lunch_time.data = ScoDocSiteConfig.get("assi_lunch_time", "13:00")
form.assi_afternoon_time.data = ScoDocSiteConfig.get(
"assi_afternoon_time", datetime.time(18, 0, 0)
"assi_afternoon_time", "18:00"
)
try:
form.assi_tick_time.data = float(