Update opolka/ScoDoc from ScoDoc/ScoDoc #2

Merged
opolka merged 1272 commits from ScoDoc/ScoDoc:master into master 2024-05-27 09:11:04 +02:00
13 changed files with 343 additions and 49 deletions
Showing only changes of commit 6dc39c25ee - Show all commits

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.scodoc import sco_utils as scu
from datetime import time
from app.scodoc.codes_cursus import (
ABAN,
ABL,
@ -94,6 +96,10 @@ class ScoDocSiteConfig(db.Model):
"cas_logout_route": str,
"cas_validate_route": str,
"cas_attribute_id": str,
# Assiduités
"morning_time": str,
"lunch_time": str,
"afternoon_time": str,
}
def __init__(self, name, value):

View File

@ -204,6 +204,7 @@ PREF_CATEGORIES = (
("misc", {"title": "Divers"}),
("apc", {"title": "BUT et Approches par Compétences"}),
("abs", {"title": "Suivi des absences", "related": ("bul",)}),
("assi", {"title": "Gestion de l'assiduité"}),
("portal", {"title": "Liaison avec portail (Apogée, etc)"}),
("apogee", {"title": "Exports Apogée"}),
(
@ -598,6 +599,38 @@ class BasePreferences(object):
"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_url",
@ -1700,7 +1733,7 @@ class BasePreferences(object):
(
"feuille_releve_abs_taille",
{
"initvalue": "A3",
"initvalue": "A4",
"input_type": "menu",
"labels": ["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);
document.querySelector(".selectors").disabled = true;
@ -1133,14 +1147,16 @@ function createMiniTimeline(assiduitesArray) {
}
};
getJustificatifFromPeriod(
{
deb: new moment.tz(assiduité.date_debut, TIMEZONE),
fin: new moment.tz(assiduité.date_fin, TIMEZONE),
},
assiduité.etudid,
action
);
if (assiduité.etudid) {
getJustificatifFromPeriod(
{
deb: new moment.tz(assiduité.date_debut, TIMEZONE),
fin: new moment.tz(assiduité.date_fin, TIMEZONE),
},
assiduité.etudid,
action
);
}
switch (assiduité.etat) {
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) {

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

View File

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

View File

@ -9,31 +9,60 @@
const timelineContainer = document.querySelector(".timeline-container");
const periodTimeLine = document.querySelector(".period");
const t_start = {{ t_start }}
const t_end = {{ t_end }}
function createTicks() {
for (let i = 8; i <= 18; i++) {
let i = t_start
while (i <= t_end) {
const hourTick = document.createElement("div");
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);
const tickLabel = document.createElement("div");
tickLabel.classList.add("tick-label");
tickLabel.style.left = `${((i - 8) / 10) * 100}%`;
tickLabel.textContent = i < 10 ? `0${i}:00` : `${i}:00`;
tickLabel.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
tickLabel.textContent = numberToTime(i);
timelineContainer.appendChild(tickLabel);
if (i < 18) {
for (let j = 1; j < 4; j++) {
const quarterTick = document.createElement("div");
quarterTick.classList.add("tick", "quarter");
quarterTick.style.left = `${((i - 8 + j / 4) / 10) * 100}%`;
timelineContainer.appendChild(quarterTick);
if (i < t_end) {
let j = Math.floor(i + 1)
while (i < j) {
i += 0.25;
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) {
return Math.round(value * 4) / 4;
}
@ -119,18 +148,28 @@
const leftPercentage = parseFloat(periodTimeLine.style.left);
const widthPercentage = parseFloat(periodTimeLine.style.width);
const startHour = (leftPercentage / 100) * 10 + 8;
const endHour = ((leftPercentage + widthPercentage) / 100) * 10 + 8;
const startHour = (leftPercentage / 100) * (t_end - t_start) + t_start;
const endHour = ((leftPercentage + widthPercentage) / 100) * (t_end - t_start) + t_start;
const startValue = Math.round(startHour * 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) {
let leftPercentage = (deb - 8) / 10 * 100
let widthPercentage = (fin - deb) / 10 * 100
let leftPercentage = (deb - t_start) / (t_end - t_start) * 100
let widthPercentage = (fin - deb) / (t_end - t_start) * 100
periodTimeLine.style.left = `${leftPercentage}%`
periodTimeLine.style.width = `${widthPercentage}%`
@ -140,12 +179,13 @@
function snapHandlesToQuarters() {
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) {
lef = 0;
}
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) {
wid = 100;
}
@ -154,7 +194,12 @@
periodTimeLine.style.width = width;
}
function computePercentage(a, b) {
return ((a - b) / (t_end - t_start)) * 100
}
createTicks();
setPeriodValues(8, 9)
</script>
<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>
</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>
<section>
<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 style="margin-top: 16px;">
🧑🏾‍🤝‍🧑🏼 <a class="stdlink" href="{{ url_for('auth.cas_users_import_config') }}">
Configurer les comptes utilisateurs pour le CAS</a>
🧑🏾‍🤝‍🧑🏼 <a class="stdlink" href="{{ url_for('auth.cas_users_import_config') }}">
Configurer les comptes utilisateurs pour le CAS</a>
</div>
<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>
(efface les modifications apportées aux rôles)
(efface les modifications apportées aux rôles)
</div>
</section>

View File

@ -10,7 +10,7 @@ from app.decorators import (
scodoc,
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 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(
header,
render_template("assiduites/minitimeline.j2"),
@ -197,10 +203,27 @@ def signal_assiduites_etud():
"assiduites/signal_assiduites_etud.j2",
sco=ScoData(etud),
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()
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")
@scodoc
@permission_required(Permission.ScoAbsChange)
@ -373,6 +396,11 @@ def signal_assiduites_group():
timeline=_timeline(),
formsemestre_date_debut=str(formsemestre.date_debut),
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(),
).build()
@ -416,4 +444,8 @@ def _module_selector(
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.config_apo import CodesDecisionsForm
from app.forms.main.config_cas import ConfigCASForm
from app.forms.main.config_assiduites import ConfigAssiduitesForm
from app import models
from app.models import Departement, Identite
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"])
@admin_required
def config_codes_decisions():

View File

@ -655,21 +655,21 @@ def profile(host, port, length, profile_dir):
"-m",
"--morning",
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,
)
@click.option(
"-n",
"--noon",
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,
)
@click.option(
"-e",
"--evening",
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,
)
@with_appcontext

View File

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