Assiduites : ajout préférences

This commit is contained in:
iziram 2023-04-25 22:59:06 +02:00
parent c984a916dc
commit 62df621d7f
13 changed files with 343 additions and 49 deletions

View File

@ -0,0 +1,86 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire configuration Module Assiduités
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField
from wtforms.fields.simple import StringField
from wtforms.widgets import TimeInput
import datetime
class TimeField(StringField):
"""HTML5 time input."""
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:
self.data = None
raise ValueError(self.gettext("Not a valid time string"))
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduités"
morning_time = TimeField("Début de la journée")
lunch_time = TimeField("Heure de midi (date pivot entre Matin et Après Midi)")
afternoon_time = TimeField("Fin de la journée")
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -8,6 +8,8 @@ from app import current_app, db, log
from app.comp import bonus_spo from app.comp import bonus_spo
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from datetime import time
from app.scodoc.codes_cursus import ( from app.scodoc.codes_cursus import (
ABAN, ABAN,
ABL, ABL,
@ -94,6 +96,10 @@ class ScoDocSiteConfig(db.Model):
"cas_logout_route": str, "cas_logout_route": str,
"cas_validate_route": str, "cas_validate_route": str,
"cas_attribute_id": str, "cas_attribute_id": str,
# Assiduités
"morning_time": str,
"lunch_time": str,
"afternoon_time": str,
} }
def __init__(self, name, value): def __init__(self, name, value):

View File

@ -206,6 +206,7 @@ PREF_CATEGORIES = (
("misc", {"title": "Divers"}), ("misc", {"title": "Divers"}),
("apc", {"title": "BUT et Approches par Compétences"}), ("apc", {"title": "BUT et Approches par Compétences"}),
("abs", {"title": "Suivi des absences", "related": ("bul",)}), ("abs", {"title": "Suivi des absences", "related": ("bul",)}),
("assi", {"title": "Gestion de l'assiduité"}),
("portal", {"title": "Liaison avec portail (Apogée, etc)"}), ("portal", {"title": "Liaison avec portail (Apogée, etc)"}),
( (
"pdf", "pdf",
@ -588,6 +589,38 @@ class BasePreferences(object):
"category": "abs", "category": "abs",
}, },
), ),
# Assiduités
(
"forcer_module",
{
"initvalue": 0,
"title": "Forcer la déclaration du module.",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "assi",
},
),
(
"forcer_present",
{
"initvalue": 0,
"title": "Forcer l'appel des présents",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "assi",
},
),
(
"etat_defaut",
{
"initvalue": "aucun",
"input_type": "menu",
"labels": ["aucun", "present", "retard", "absent"],
"allowed_values": ["aucun", "present", "retard", "absent"],
"title": "Définir l'état par défaut",
"category": "assi",
},
),
# portal # portal
( (
"portal_url", "portal_url",
@ -1678,7 +1711,7 @@ class BasePreferences(object):
( (
"feuille_releve_abs_taille", "feuille_releve_abs_taille",
{ {
"initvalue": "A3", "initvalue": "A4",
"input_type": "menu", "input_type": "menu",
"labels": ["A3", "A4"], "labels": ["A3", "A4"],
"allowed_values": ["A3", "A4"], "allowed_values": ["A3", "A4"],

View File

@ -88,6 +88,20 @@ function validateSelectors() {
); );
}); });
if (getModuleImplId() == null && forceModule) {
const HTML = `
<p>Attention, le module doit obligatoirement être renseigné.</p>
<p>Cela vient de la configuration du semestre ou plus largement du département.</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`;
const content = document.createElement("div");
content.innerHTML = HTML;
openAlertModal("Sélection du module", content);
return;
}
getAssiduitesFromEtuds(true); getAssiduitesFromEtuds(true);
document.querySelector(".selectors").disabled = true; document.querySelector(".selectors").disabled = true;
@ -1133,14 +1147,16 @@ function createMiniTimeline(assiduitesArray) {
} }
}; };
getJustificatifFromPeriod( if (assiduité.etudid) {
{ getJustificatifFromPeriod(
deb: new moment.tz(assiduité.date_debut, TIMEZONE), {
fin: new moment.tz(assiduité.date_fin, TIMEZONE), deb: new moment.tz(assiduité.date_debut, TIMEZONE),
}, fin: new moment.tz(assiduité.date_fin, TIMEZONE),
assiduité.etudid, },
action assiduité.etudid,
); action
);
}
switch (assiduité.etat) { switch (assiduité.etat) {
case "PRESENT": case "PRESENT":
@ -1905,7 +1921,9 @@ function fastJustify(assiduite) {
); );
} }
}; };
getJustificatifFromPeriod(period, assiduite.etudid, action); if (assiduite.etudid) {
getJustificatifFromPeriod(period, assiduite.etudid, action);
}
} }
function justifyAssiduite(assiduite_id, justified) { function justifyAssiduite(assiduite_id, justified) {

View File

@ -0,0 +1,28 @@
{% extends "base.j2" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Configuration du Module d'assiduité</h1>
<div class="row">
<div class="col-md-8">
<form class="form form-horizontal" method="post" enctype="multipart/form-data" role="form">
{{ form.hidden_tag() }}
{{ wtf.form_errors(form, hiddens="only") }}
{{ wtf.form_field(form.morning_time) }}
{{ wtf.form_field(form.lunch_time) }}
{{ wtf.form_field(form.afternoon_time) }}
<div class="form-group">
{{ wtf.form_field(form.submit) }}
{{ wtf.form_field(form.cancel) }}
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -49,9 +49,9 @@
</div> </div>
<div class="btn_group"> <div class="btn_group">
<button class="btn" onclick="setTimeLineTimes(8,18)">Journée</button> <button class="btn" onclick="setTimeLineTimes({{morning}},{{afternoon}})">Journée</button>
<button class="btn" onclick="setTimeLineTimes(8,13)">Matin</button> <button class="btn" onclick="setTimeLineTimes({{morning}},{{lunch}})">Matin</button>
<button class="btn" onclick="setTimeLineTimes(13,18)">Après-midi</button> <button class="btn" onclick="setTimeLineTimes({{lunch}},{{afternoon}})">Après-midi</button>
</div> </div>
<div class="etud_holder"> <div class="etud_holder">
@ -94,6 +94,9 @@
} }
let forceModule = "{{ forcer_module }}"
forceModule = forceModule == "True" ? true : false
</script> </script>

View File

@ -73,5 +73,8 @@
updateDate(); updateDate();
setupDate(); setupDate();
setupTimeLine(); setupTimeLine();
let forceModule = "{{ forcer_module }}"
forceModule = forceModule == "True" ? true : false
</script> </script>
</section> </section>

View File

@ -9,31 +9,60 @@
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_end = {{ t_end }}
function createTicks() { function createTicks() {
for (let i = 8; i <= 18; i++) { let i = t_start
while (i <= t_end) {
const hourTick = document.createElement("div"); const hourTick = document.createElement("div");
hourTick.classList.add("tick", "hour"); hourTick.classList.add("tick", "hour");
hourTick.style.left = `${((i - 8) / 10) * 100}%`; hourTick.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
timelineContainer.appendChild(hourTick); timelineContainer.appendChild(hourTick);
const tickLabel = document.createElement("div"); const tickLabel = document.createElement("div");
tickLabel.classList.add("tick-label"); tickLabel.classList.add("tick-label");
tickLabel.style.left = `${((i - 8) / 10) * 100}%`; tickLabel.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
tickLabel.textContent = i < 10 ? `0${i}:00` : `${i}:00`; tickLabel.textContent = numberToTime(i);
timelineContainer.appendChild(tickLabel); timelineContainer.appendChild(tickLabel);
if (i < 18) { if (i < t_end) {
for (let j = 1; j < 4; j++) { let j = Math.floor(i + 1)
const quarterTick = document.createElement("div");
quarterTick.classList.add("tick", "quarter"); while (i < j) {
quarterTick.style.left = `${((i - 8 + j / 4) / 10) * 100}%`; i += 0.25;
timelineContainer.appendChild(quarterTick);
if (i <= t_end) {
const quarterTick = document.createElement("div");
quarterTick.classList.add("tick", "quarter");
quarterTick.style.left = `${computePercentage(i, t_start)}%`;
timelineContainer.appendChild(quarterTick);
}
} }
} }
} }
} }
function numberToTime(num) {
const integer = Math.floor(num)
const decimal = (num % 1) * 60
let dec = `:${decimal}`
if (decimal < 10) {
dec = `:0${decimal}`
}
let int = `${integer}`
if (integer < 10) {
int = `0${integer}`
}
return int + dec
}
function snapToQuarter(value) { function snapToQuarter(value) {
return Math.round(value * 4) / 4; return Math.round(value * 4) / 4;
} }
@ -119,18 +148,28 @@
const leftPercentage = parseFloat(periodTimeLine.style.left); const leftPercentage = parseFloat(periodTimeLine.style.left);
const widthPercentage = parseFloat(periodTimeLine.style.width); const widthPercentage = parseFloat(periodTimeLine.style.width);
const startHour = (leftPercentage / 100) * 10 + 8; const startHour = (leftPercentage / 100) * (t_end - t_start) + t_start;
const endHour = ((leftPercentage + widthPercentage) / 100) * 10 + 8; const endHour = ((leftPercentage + widthPercentage) / 100) * (t_end - t_start) + t_start;
const startValue = Math.round(startHour * 4) / 4; const startValue = Math.round(startHour * 4) / 4;
const endValue = Math.round(endHour * 4) / 4; const endValue = Math.round(endHour * 4) / 4;
return [startValue, endValue] const computedValues = [Math.max(startValue, t_start), Math.min(t_end, endValue)]
if (computedValues[0] > t_end || computedValues[1] < t_start) {
return [8, 10]
}
if (computedValues[1] - computedValues[0] <= 0.25 && computedValues[1] < t_end - 0.25) {
computedValues[1] += 0.25;
}
return computedValues
} }
function setPeriodValues(deb, fin) { function setPeriodValues(deb, fin) {
let leftPercentage = (deb - 8) / 10 * 100 let leftPercentage = (deb - t_start) / (t_end - t_start) * 100
let widthPercentage = (fin - deb) / 10 * 100 let widthPercentage = (fin - deb) / (t_end - t_start) * 100
periodTimeLine.style.left = `${leftPercentage}%` periodTimeLine.style.left = `${leftPercentage}%`
periodTimeLine.style.width = `${widthPercentage}%` periodTimeLine.style.width = `${widthPercentage}%`
@ -140,12 +179,13 @@
function snapHandlesToQuarters() { function snapHandlesToQuarters() {
const periodValues = getPeriodValues(); const periodValues = getPeriodValues();
let lef = Math.min((periodValues[0] - 8) * 10, 97.5) let lef = Math.min(computePercentage(periodValues[0], t_start), computePercentage(t_end, 0.25))
if (lef < 0) { if (lef < 0) {
lef = 0; lef = 0;
} }
const left = `${lef}%` const left = `${lef}%`
let wid = Math.max((periodValues[1] - periodValues[0]) * 10, 2.5)
let wid = Math.max(computePercentage(periodValues[1], periodValues[0]), computePercentage(0.25, 0))
if (wid > 100) { if (wid > 100) {
wid = 100; wid = 100;
} }
@ -154,7 +194,12 @@
periodTimeLine.style.width = width; periodTimeLine.style.width = width;
} }
function computePercentage(a, b) {
return ((a - b) / (t_end - t_start)) * 100
}
createTicks(); createTicks();
setPeriodValues(8, 9)
</script> </script>
<style> <style>

View File

@ -52,20 +52,25 @@
<p><a class="stdlink" href="{{url_for('scodoc.config_codes_decisions')}}">configuration des codes de décision</a> <p><a class="stdlink" href="{{url_for('scodoc.config_codes_decisions')}}">configuration des codes de décision</a>
</p> </p>
</section> </section>
<section>
<h2>Assiduités</h2>
<p><a class="stdlink" href="{{url_for('scodoc.config_assiduites')}}">configuration du module d'assiduités</a>
</p>
</section>
<h2>Utilisateurs et CAS</h2> <h2>Utilisateurs et CAS</h2>
<section> <section>
<div> <div>
🏰 <a class="stdlink" href="{{url_for('scodoc.config_cas')}}">Configuration du service CAS</a> 🏰 <a class="stdlink" href="{{url_for('scodoc.config_cas')}}">Configuration du service CAS</a>
</div> </div>
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
🧑🏾‍🤝‍🧑🏼 <a class="stdlink" href="{{ url_for('auth.cas_users_import_config') }}"> 🧑🏾‍🤝‍🧑🏼 <a class="stdlink" href="{{ url_for('auth.cas_users_import_config') }}">
Configurer les comptes utilisateurs pour le CAS</a> Configurer les comptes utilisateurs pour le CAS</a>
</div> </div>
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
🛟 <a class="stdlink" href="{{url_for('auth.reset_standard_roles_permissions')}}">Remettre 🛟 <a class="stdlink" href="{{url_for('auth.reset_standard_roles_permissions')}}">Remettre
les permissions des rôles standards à leurs valeurs par défaut</a> les permissions des rôles standards à leurs valeurs par défaut</a>
(efface les modifications apportées aux rôles) (efface les modifications apportées aux rôles)
</div> </div>
</section> </section>

View File

@ -10,7 +10,7 @@ from app.decorators import (
scodoc, scodoc,
permission_required, permission_required,
) )
from app.models import FormSemestre, Identite from app.models import FormSemestre, Identite, ScoDocSiteConfig
from app.views import assiduites_bp as bp from app.views import assiduites_bp as bp
from app.views import ScoData from app.views import ScoData
@ -190,6 +190,12 @@ def signal_assiduites_etud():
], ],
) )
# Gestion des horaires (journée, matin, soir)
morning = get_time("assi_morning_time", "08:00:00")
lunch = get_time("assi_lunch_time", "13:00:00")
afternoon = get_time("assi_afternoon_time", "18:00:00")
return HTMLBuilder( return HTMLBuilder(
header, header,
render_template("assiduites/minitimeline.j2"), render_template("assiduites/minitimeline.j2"),
@ -197,10 +203,27 @@ def signal_assiduites_etud():
"assiduites/signal_assiduites_etud.j2", "assiduites/signal_assiduites_etud.j2",
sco=ScoData(etud), sco=ScoData(etud),
date=datetime.date.today().isoformat(), date=datetime.date.today().isoformat(),
morning=morning,
lunch=lunch,
afternoon=afternoon,
forcer_module=sco_preferences.get_preference(
"forcer_module", dept_id=g.scodoc_dept_id
),
), ),
).build() ).build()
def _str_to_num(string: str):
parts = [*map(float, string.split(":"))]
hour = parts[0]
minutes = round(parts[1] / 60 * 4) / 4
return hour + minutes
def get_time(label: str, default: str):
return _str_to_num(ScoDocSiteConfig.get(label, default))
@bp.route("/ListeAssiduitesEtud") @bp.route("/ListeAssiduitesEtud")
@scodoc @scodoc
@permission_required(Permission.ScoAbsChange) @permission_required(Permission.ScoAbsChange)
@ -373,6 +396,11 @@ def signal_assiduites_group():
timeline=_timeline(), timeline=_timeline(),
formsemestre_date_debut=str(formsemestre.date_debut), formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin), formsemestre_date_fin=str(formsemestre.date_fin),
forcer_module=sco_preferences.get_preference(
"forcer_module",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
),
), ),
html_sco_header.sco_footer(), html_sco_header.sco_footer(),
).build() ).build()
@ -416,4 +444,8 @@ def _module_selector(
def _timeline() -> HTMLElement: def _timeline() -> HTMLElement:
return render_template("assiduites/timeline.j2") return render_template(
"assiduites/timeline.j2",
t_start=get_time("assi_morning_time", "08:00:00"),
t_end=get_time("assi_afternoon_time", "18:00:00"),
)

View File

@ -64,6 +64,7 @@ from app.forms.main import config_logos, config_main
from app.forms.main.create_dept import CreateDeptForm from app.forms.main.create_dept import CreateDeptForm
from app.forms.main.config_apo import CodesDecisionsForm from app.forms.main.config_apo import CodesDecisionsForm
from app.forms.main.config_cas import ConfigCASForm from app.forms.main.config_cas import ConfigCASForm
from app.forms.main.config_assiduites import ConfigAssiduitesForm
from app import models from app import models
from app.models import Departement, Identite from app.models import Departement, Identite
from app.models import departements from app.models import departements
@ -188,6 +189,39 @@ def config_cas():
) )
@bp.route("/ScoDoc/config_assiduites", methods=["GET", "POST"])
@admin_required
def config_assiduites():
"""Form config Assiduites"""
form = ConfigAssiduitesForm()
if request.method == "POST" and form.cancel.data: # cancel button
return redirect(url_for("scodoc.index"))
if form.validate_on_submit():
if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]):
flash("Heure du début de la journée enregistrée")
if ScoDocSiteConfig.set("assi_lunch_time", form.data["lunch_time"]):
flash("Heure de midi enregistrée")
if ScoDocSiteConfig.set("assi_afternoon_time", form.data["afternoon_time"]):
flash("Heure de fin de la journée enregistrée")
return redirect(url_for("scodoc.configuration"))
elif request.method == "GET":
form.morning_time.data = ScoDocSiteConfig.get(
"assi_morning_time", datetime.time(8, 0, 0)
)
form.lunch_time.data = ScoDocSiteConfig.get(
"assi_lunch_time", datetime.time(13, 0, 0)
)
form.afternoon_time.data = ScoDocSiteConfig.get(
"assi_afternoon_time", datetime.time(18, 0, 0)
)
return render_template(
"assiduites/config_assiduites.j2",
form=form,
title="Configuration du module Assiduités",
)
@bp.route("/ScoDoc/config_codes_decisions", methods=["GET", "POST"]) @bp.route("/ScoDoc/config_codes_decisions", methods=["GET", "POST"])
@admin_required @admin_required
def config_codes_decisions(): def config_codes_decisions():

View File

@ -655,21 +655,21 @@ def profile(host, port, length, profile_dir):
"-m", "-m",
"--morning", "--morning",
help="Spécifie l'heure de début des cours format `hh:mm`", help="Spécifie l'heure de début des cours format `hh:mm`",
default="08h00", default="Heure configurée dans la configuration générale / 08:00 sinon",
show_default=True, show_default=True,
) )
@click.option( @click.option(
"-n", "-n",
"--noon", "--noon",
help="Spécifie l'heure de fin du matin (et donc début de l'après-midi) format `hh:mm`", help="Spécifie l'heure de fin du matin (et donc début de l'après-midi) format `hh:mm`",
default="12h00", default="Heure configurée dans la configuration générale / 13:00 sinon",
show_default=True, show_default=True,
) )
@click.option( @click.option(
"-e", "-e",
"--evening", "--evening",
help="Spécifie l'heure de fin des cours format `hh:mm`", help="Spécifie l'heure de fin des cours format `hh:mm`",
default="18h00", default="Heure configurée dans la configuration générale / 18:00 sinon",
show_default=True, show_default=True,
) )
@with_appcontext @with_appcontext

View File

@ -16,6 +16,9 @@ from app.models import (
Justificatif, Justificatif,
ModuleImplInscription, ModuleImplInscription,
) )
from app.models.config import ScoDocSiteConfig
from app.models.assiduites import ( from app.models.assiduites import (
compute_assiduites_justified, compute_assiduites_justified,
) )
@ -181,7 +184,6 @@ class _Statistics:
"""Comptage des statistiques""" """Comptage des statistiques"""
stats: dict = {"total": self.object["total"]} stats: dict = {"total": self.object["total"]}
for year, item in self.object.items(): for year, item in self.object.items():
if year == "total": if year == "total":
continue continue
@ -226,21 +228,21 @@ def migrate_abs_to_assiduites(
_glob.DEBUG = debug _glob.DEBUG = debug
if morning is None: if morning is None:
_glob.MORNING = time(8, 0) _glob.MORNING = ScoDocSiteConfig.get("assi_morning_time", time(8, 0))
else: else:
morning: list[str] = morning.split("h") morning: list[str] = morning.split(":")
_glob.MORNING = time(int(morning[0]), int(morning[1])) _glob.MORNING = time(int(morning[0]), int(morning[1]))
if noon is None: if noon is None:
_glob.NOON = time(12, 0) _glob.NOON = ScoDocSiteConfig.get("assi_lunch_time", time(13, 0))
else: else:
noon: list[str] = noon.split("h") noon: list[str] = noon.split(":")
_glob.NOON = time(int(noon[0]), int(noon[1])) _glob.NOON = time(int(noon[0]), int(noon[1]))
if evening is None: if evening is None:
_glob.EVENING = time(18, 0) _glob.EVENING = ScoDocSiteConfig.get("assi_afternoon_time", time(18, 0))
else: else:
evening: list[str] = evening.split("h") evening: list[str] = evening.split(":")
_glob.EVENING = time(int(evening[0]), int(evening[1])) _glob.EVENING = time(int(evening[0]), int(evening[1]))
if dept is None: if dept is None:
@ -379,7 +381,6 @@ def migrate_dept(dept_name: str, stats: _Statistics, time_elapsed: Profiler):
def _from_abs_to_assiduite_justificatif(_abs: Absence): def _from_abs_to_assiduite_justificatif(_abs: Absence):
if _abs.etudid not in _glob.CURRENT_ETU: if _abs.etudid not in _glob.CURRENT_ETU:
etud: Identite = Identite.query.filter_by(id=_abs.etudid).first() etud: Identite = Identite.query.filter_by(id=_abs.etudid).first()
if etud is None: if etud is None: