Assiduité: fix bug fuseau horaire dans l'UI JS. Ajout API et test.

This commit is contained in:
Emmanuel Viennet 2024-10-29 16:40:42 +01:00
parent d1a2e52fef
commit 8b01df0b02
7 changed files with 101 additions and 22 deletions

View File

@ -5,7 +5,8 @@
############################################################################## ##############################################################################
"""ScoDoc 9 API : Assiduités""" """ScoDoc 9 API : Assiduités"""
from datetime import datetime from datetime import datetime, timedelta
import re
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
@ -39,6 +40,24 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
@bp.route("/assiduite/date_time_offset/<string:date_iso>")
@api_web_bp.route("/assiduite/date_time_offset/<string:date_iso>")
@scodoc
@permission_required(Permission.ScoView)
def date_time_offset(date_iso: str):
"""L'offset dans le fuseau horaire du serveur pour la date indiquée.
Renvoie une chaîne de la forme "+04:00" (ISO 8601)
Exemple: `/assiduite/date_time_offset/2024-10-01` renvoie `'+02:00'`
"""
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_iso):
json_error(
404,
message="date invalide",
)
return scu.get_local_timezone_offset(date_iso)
@bp.route("/assiduite/<int:assiduite_id>") @bp.route("/assiduite/<int:assiduite_id>")
@api_web_bp.route("/assiduite/<int:assiduite_id>") @api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc @scodoc
@ -843,6 +862,10 @@ def _create_one(
elif fin.tzinfo is None: elif fin.tzinfo is None:
fin: datetime = scu.localize_datetime(fin) fin: datetime = scu.localize_datetime(fin)
# check duration: min 1 minute
if (deb is not None) and (fin is not None) and (fin - deb) < timedelta(seconds=60):
errors.append("durée trop courte")
# cas 4 : desc # cas 4 : desc
desc: str = data.get("desc", None) desc: str = data.get("desc", None)

View File

@ -441,12 +441,17 @@ def localize_datetime(date: datetime.datetime) -> datetime.datetime:
return new_date return new_date
def get_local_timezone_offset() -> str: def get_local_timezone_offset(date_iso: str | None = None) -> str:
"""Récupère l'offset de la timezone du serveur, sous la forme """Récupère l'offset de la timezone du serveur, sous la forme
"+HH:MM" "+HH:MM", pour le jour indiqué (date courante par défaut).
Par exemple get_local_timezone_offset("2024-10-30") == "+01:00"
""" """
local_time = datetime.datetime.now().astimezone() the_time = (
utc_offset = local_time.utcoffset() datetime.datetime.now()
if date_iso is None
else datetime.datetime.fromisoformat(date_iso)
)
utc_offset = the_time.astimezone().utcoffset()
total_seconds = int(utc_offset.total_seconds()) total_seconds = int(utc_offset.total_seconds())
offset_hours = total_seconds // 3600 offset_hours = total_seconds // 3600
offset_minutes = (abs(total_seconds) % 3600) // 60 offset_minutes = (abs(total_seconds) % 3600) // 60

View File

@ -460,6 +460,19 @@ async function creerTousLesEtudiants(etuds) {
.forEach((etud, index) => { .forEach((etud, index) => {
etudsDiv.appendChild(creerLigneEtudiant(etud, index + 1)); etudsDiv.appendChild(creerLigneEtudiant(etud, index + 1));
}); });
// Récupère l'offset timezone serveur pour la date sélectionnée
const date_iso = getSelectedDateIso();
try {
const res = await fetch(`../../api/assiduite/date_time_offset/${date_iso}`);
if (!res.ok) {
throw new Error("Network response was not ok");
}
const text = await res.text();
console.log(text);
SERVER_TIMEZONE_OFFSET = text;
} catch (error) {
console.error('Error:', error);
}
} }
/** /**
@ -609,7 +622,7 @@ async function actionAssiduite(etud, etat, type, assiduite = null) {
const modimpl_id = $("#moduleimpl_select").val(); const modimpl_id = $("#moduleimpl_select").val();
if (assiduite && assiduite.etat.toLowerCase() === etat) type = "suppression"; if (assiduite && assiduite.etat.toLowerCase() === etat) type = "suppression";
const { deb, fin } = getPeriodAsDate(true); // en tz server const { deb, fin } = getPeriodAsISO(); // chaines sans timezone pour l'API
// génération d'un objet assiduité basique qui sera complété // génération d'un objet assiduité basique qui sera complété
let assiduiteObjet = assiduite ?? { let assiduiteObjet = assiduite ?? {
date_debut: deb, date_debut: deb,
@ -722,9 +735,12 @@ function mettreToutLeMonde(etat, el = null) {
const lignesEtuds = [...document.querySelectorAll("fieldset.btns_field")]; const lignesEtuds = [...document.querySelectorAll("fieldset.btns_field")];
const { deb, fin } = getPeriodAsDate(true); // tz server const { deb, fin } = getPeriodAsDate(true); // tz server
const period_iso = getPeriodAsISO(); // chaines sans timezone pour l'API
const deb_iso = period_iso.deb;
const fin_iso = period_iso.fin;
const assiduiteObjet = { const assiduiteObjet = {
date_debut: deb, date_debut: deb_iso,
date_fin: fin, date_fin: fin_iso,
etat: etat, etat: etat,
moduleimpl_id: $("#moduleimpl_select").val(), moduleimpl_id: $("#moduleimpl_select").val(),
}; };
@ -741,14 +757,14 @@ function mettreToutLeMonde(etat, el = null) {
.filter((e) => e.getAttribute("type") == "edition") .filter((e) => e.getAttribute("type") == "edition")
.map((e) => Number(e.getAttribute("assiduite_id"))); .map((e) => Number(e.getAttribute("assiduite_id")));
// On récupère les assiduités conflictuelles mais qui sont comprisent // On récupère les assiduités conflictuelles mais qui sont comprises
// Dans la plage de suppression // dans la plage de suppression
const unDeleted = {}; const unDeleted = {};
lignesEtuds lignesEtuds
.filter((e) => e.getAttribute("type") == "conflit") .filter((e) => e.getAttribute("type") == "conflit")
.forEach((e) => { .forEach((e) => {
const etud = etuds.get(Number(e.getAttribute("etudid"))); const etud = etuds.get(Number(e.getAttribute("etudid")));
// On récupère les assiduités couvertent par la plage de suppression // On récupère les assiduités couvertes par la plage de suppression
etud.assiduites.forEach((a) => { etud.assiduites.forEach((a) => {
const date_debut = new Date(a.date_debut); const date_debut = new Date(a.date_debut);
const date_fin = new Date(a.date_fin); const date_fin = new Date(a.date_fin);
@ -756,8 +772,8 @@ function mettreToutLeMonde(etat, el = null) {
// (qui intersectent la plage de suppression) // (qui intersectent la plage de suppression)
if ( if (
Date.intersect( Date.intersect(
{ deb: deb, fin: fin }, { deb: deb, fin: fin }, // la plage, en Date avec timezone serveur
{ deb: date_debut, fin: date_fin } { deb: date_debut, fin: date_fin } // dates de l'assiduité avec leur timezone
) )
) { ) {
// Si l'assiduité est couverte par la plage de suppression // Si l'assiduité est couverte par la plage de suppression

View File

@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<script> <script>
const SERVER_TIMEZONE_OFFSET = "{{ scu.get_local_timezone_offset() }}"; var SERVER_TIMEZONE_OFFSET = "{{ scu.get_local_timezone_offset() }}"; // modifié par creerTousLesEtudiants()
const timelineContainer = document.querySelector(".timeline-container"); const timelineContainer = document.querySelector(".timeline-container");
const periodTimeLine = document.querySelector(".period"); const periodTimeLine = document.querySelector(".period");
const t_start = {{ t_start }}; const t_start = {{ t_start }};
@ -336,7 +336,14 @@
const [hours, minutes] = time.split(separator).map((el) => Number(el)) const [hours, minutes] = time.split(separator).map((el) => Number(el))
return hours + minutes / 60 return hours + minutes / 60
} }
// Renvoie les valeurs de la période sous forme de date // La date ISO du datepicker
function getSelectedDateIso() {
return $("#date")
.datepicker("getDate")
.format("yyyy-mm-dd")
.substring(0, 10); // récupération que de la date, pas des heures
}
// Renvoie les valeurs de la période sous forme de Date
// Les heures sont récupérées depuis la timeline // Les heures sont récupérées depuis la timeline
// la date est récupérée depuis un champ "#date" (datepicker) // la date est récupérée depuis un champ "#date" (datepicker)
function getPeriodAsDate(add_server_tz = false) { function getPeriodAsDate(add_server_tz = false) {
@ -344,17 +351,30 @@
deb = numberToTime(deb); deb = numberToTime(deb);
fin = numberToTime(fin); fin = numberToTime(fin);
const dateStr = getSelectedDateIso();
// Les heures deb et fin sont telles qu'affichées, c'est à dire
// en heure locale DU SERVEUR (des étudiants donc)
let offset = add_server_tz ? SERVER_TIMEZONE_OFFSET : "";
return {
deb: new Date(`${dateStr}T${deb}${offset}`),
fin: new Date(`${dateStr}T${fin}${offset}`)
}
}
// Renvoie les valeurs de la période sous forme de chaine ISO sans time zone.
function getPeriodAsISO() {
let [deb, fin] = getPeriodValues();
deb = numberToTime(deb);
fin = numberToTime(fin);
const dateStr = $("#date") const dateStr = $("#date")
.datepicker("getDate") .datepicker("getDate")
.format("yyyy-mm-dd") .format("yyyy-mm-dd")
.substring(0, 10); // récupération que de la date, pas des heures .substring(0, 10); // récupération que de la date, pas des heures
// retourne des chaines ISO sans timezone
// Les heures deb et fin sont telles qu'affichées, c'est à dire
// en heure locale DU SERVEUR (des étudiants donc)
let offset = add_server_tz ? SERVER_TIMEZONE_OFFSET : "";
return { return {
deb: new Date(`${dateStr}T${deb}${offset}`), deb : `${dateStr}T${deb}`,
fin: new Date(`${dateStr}T${fin}${offset}`) fin : `${dateStr}T${fin}`
} }
} }
// Sauvegarde les valeurs de la période dans le local storage // Sauvegarde les valeurs de la période dans le local storage

View File

@ -3,7 +3,7 @@
"Infos sur version ScoDoc" "Infos sur version ScoDoc"
SCOVERSION = "9.7.34" SCOVERSION = "9.7.35"
SCONAME = "ScoDoc" SCONAME = "ScoDoc"

View File

@ -7,6 +7,7 @@ Ecrit par HARTMANN Matthias
import datetime import datetime
from random import randint from random import randint
import re
from types import NoneType from types import NoneType
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -449,3 +450,16 @@ def test_route_delete(api_admin_headers):
assert len(res["errors"]) == 3 assert len(res["errors"]) == 3
assert all(i["message"] == "Assiduite non existante" for i in res["errors"]) assert all(i["message"] == "Assiduite non existante" for i in res["errors"])
def test_date_time_offset(api_headers):
"""test de la route /assiduites/date_time_offset"""
reply = GET(
path="/assiduite/date_time_offset/2024-10-29",
headers=api_headers,
dept=DEPT_ACRONYM,
raw=True,
)
# offset ISO 8601 de la forme +/-hh:mm
assert re.match(r"^(Z|[+-]\d{2}:\d{2})$", reply.text)

View File

@ -42,6 +42,7 @@ def test_permissions(api_headers):
"acronym": "TAPI", "acronym": "TAPI",
"code_type": "etudid", "code_type": "etudid",
"code": 1, "code": 1,
"date_iso": "2024-10-29",
"dept_id": 1, "dept_id": 1,
"dept_ident": "TAPI", "dept_ident": "TAPI",
"dept": "TAPI", "dept": "TAPI",