1
0
forked from ScoDoc/ScoDoc
This commit is contained in:
Emmanuel Viennet 2023-11-13 23:15:18 +01:00
commit d5c15cc5c5
17 changed files with 447 additions and 113 deletions

View File

@ -4,7 +4,7 @@ Formulaire configuration des codes Apo et EDT des modimps d'un formsemestre
from flask_wtf import FlaskForm
from wtforms import validators
from wtforms.fields.simple import BooleanField, StringField, SubmitField
from wtforms.fields.simple import StringField, SubmitField
from app.models import FormSemestre, ModuleImpl

View File

@ -29,6 +29,7 @@
Formulaire configuration Module Assiduités
"""
import datetime
import re
from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError
@ -98,6 +99,23 @@ def check_ics_path(form, field):
raise ValidationError("Le chemin vers les ics doit utiliser {edt_id}")
def check_ics_field(form, field):
"""Vérifie que c'est un nom de champ crédible: un mot alphanumérique"""
if not re.match(r"^[a-zA-Z\-_0-9]+$", field.data):
raise ValidationError("nom de champ ics invalide")
def check_ics_regexp(form, field):
"""Vérifie que field est une expresssion régulière"""
value = field.data.strip()
# check that it compiles
try:
_ = re.compile(value)
except re.error as exc:
raise ValidationError("expression invalide") from exc
return True
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité"
@ -120,5 +138,45 @@ class ConfigAssiduitesForm(FlaskForm):
validators=[Optional(), check_ics_path],
)
edt_ics_title_field = StringField(
label="Champs contenant le titre",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_title_regexp = StringField(
label="Extraction du titre",
description=r"""expression régulière python dont le premier groupe doit
sera le titre de l'évènement affcihé dans le calendrier ScoDoc.
Exemple: <tt>Matière : \w+ - ([\w\.\s']+)</tt>
""",
validators=[Optional(), check_ics_regexp],
)
edt_ics_group_field = StringField(
label="Champs contenant le groupe",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_group_regexp = StringField(
label="Extraction du groupe",
description=r"""expression régulière python dont le premier groupe doit
correspondre à l'identifiant de groupe de l'emploi du temps.
Exemple: <tt>.*- ([\w\s]+)$</tt>
""",
validators=[Optional(), check_ics_regexp],
)
edt_ics_mod_field = StringField(
label="Champs contenant le module",
description="""champ de l'évènement calendrier: DESCRIPTION, SUMMARY, ...""",
validators=[Optional(), check_ics_field],
)
edt_ics_mod_regexp = StringField(
label="Extraction du module",
description=r"""expression régulière python dont le premier groupe doit
correspondre à l'identifiant (code) du module de l'emploi du temps.
Exemple: <tt>Matière : ([A-Z][A-Z0-9]+)</tt>
""",
validators=[Optional(), check_ics_regexp],
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -631,7 +631,7 @@ class FormSemestre(db.Model):
def can_change_groups(self, user: User = None) -> bool:
"""Vrai si l'utilisateur (par def. current) peut changer les groupes dans
ce semestre: vérifie permission et verrouillage.
ce semestre: vérifie permission et verrouillage (mais pas si la partition est éditable).
"""
if not self.etat:
return False # semestre verrouillé

View File

@ -282,18 +282,18 @@ class GroupDescr(db.Model):
return False
return True
def set_name(
self, group_name: str, edt_id: str | bool = False, dest_url: str = None
):
def set_name(self, group_name: str, dest_url: str = None):
"""Set group name, and optionally edt_id.
Check permission and invalidate caches. Commit session.
Check permission (partition must be groups_editable)
and invalidate caches. Commit session.
dest_url is used for error messages.
"""
if not self.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if self.group_name is None:
raise ValueError("can't set a name to default group")
if not self.partition.groups_editable:
raise AccessDenied("Partition non éditable")
if group_name:
group_name = group_name.strip()
if not group_name:
@ -306,16 +306,22 @@ class GroupDescr(db.Model):
)
self.group_name = group_name
if edt_id is not False:
if isinstance(edt_id, str):
edt_id = edt_id.strip() or None
self.edt_id = edt_id
db.session.add(self)
db.session.commit()
sco_cache.invalidate_formsemestre(
formsemestre_id=self.partition.formsemestre_id
)
def set_edt_id(self, edt_id: str):
"Set edt_id. Check permission. Commit session."
if not self.partition.formsemestre.can_change_groups():
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if isinstance(edt_id, str):
edt_id = edt_id.strip() or None
self.edt_id = edt_id
db.session.add(self)
db.session.commit()
def remove_etud(self, etud: "Identite"):
"Enlève l'étudiant de ce groupe s'il en fait partie (ne fait rien sinon)"
if etud in self.etuds:

View File

@ -36,6 +36,7 @@ import icalendar
from flask import flash, g, url_for
from app import log
from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.sco_utils as scu
@ -67,6 +68,7 @@ def formsemestre_load_calendar(
return calendar
# --- Couleurs des évènements emploi du temps
_COLOR_PALETTE = [
"#ff6961",
"#ffb480",
@ -77,15 +79,118 @@ _COLOR_PALETTE = [
"#9d94ff",
"#c780e8",
]
_EVENT_DEFAULT_COLOR = "rgb(214, 233, 248)"
def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]:
"""EDT complet du semestre, comme une liste de dict serialisable en json.
Fonction appellée par l'API /formsemestre/<int:formsemestre_id>/edt
TODO: spécifier intervalle de dates start et end
TODO: cacher ?
"""
# Correspondances id edt -> id scodoc pour groupes, modules et enseignants
events_scodoc = _load_and_convert_ics(formsemestre)
# Génération des événements pour le calendrier html
events_cal = []
for event in events_scodoc:
group: GroupDescr | bool = event["group"]
if group is False:
group_disp = f"""<div class="group-edt">
<span title="extraction emploi du temps non configurée">
{scu.EMO_WARNING} non configuré</span>
</div>"""
else:
group_disp = (
f"""<div class="group-name">{group.get_nom_with_part(default="promo")}</div>"""
if group
else f"""<div class="group-edt">{event['edt_group']}
<span title="vérifier noms de groupe ou configuration extraction edt">
{scu.EMO_WARNING} non reconnu</span>
</div>"""
)
modimpl: ModuleImpl | bool = event["modimpl"]
if modimpl is False:
mod_disp = f"""<div class="module-edt" title="extraction emploi du temps non configurée">
{scu.EMO_WARNING} non configuré
</div>"""
else:
mod_disp = (
f"""<div class="module-edt mod-name" title="{modimpl.module.abbrev or ""}">{
modimpl.module.code}</div>"""
if modimpl
else f"""<div class="module-edt mod-etd" title="code module non trouvé dans ScoDoc.
Vérifier configuration.">{
scu.EMO_WARNING} {event['edt_module']}</div>"""
)
# --- Lien saisie abs
link_abs = (
f"""<div class="module-edt link-abs"><a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"],
moduleimpl_id=modimpl.id,
jour = event["jour"],
)}">absences</a>
</div>"""
if modimpl and group
else ""
)
d = {
# Champs utilisés par tui.calendar
"calendarId": "cal1",
"title": event["title"] + group_disp + mod_disp + link_abs,
"start": event["start"],
"end": event["end"],
"backgroundColor": event["group_bg_color"],
# Infos brutes pour usage API éventuel
"group_id": group.id if group else None,
"group_edt_id": event["edt_group"],
"moduleimpl_id": modimpl.id if modimpl else None,
}
events_cal.append(d)
return events_cal
def _load_and_convert_ics(formsemestre: FormSemestre) -> list[dict]:
"chargement fichier, filtrage et extraction des identifiants."
# Chargement du calendier ics
calendar = formsemestre_load_calendar(formsemestre)
if not calendar:
return []
# --- Paramètres d'extraction
edt_ics_title_field = ScoDocSiteConfig.get("edt_ics_title_field")
edt_ics_title_regexp = ScoDocSiteConfig.get("edt_ics_title_regexp")
try:
edt_ics_title_pattern = (
re.compile(edt_ics_title_regexp) if edt_ics_title_regexp else None
)
except re.error as exc:
raise ScoValueError(
"expression d'extraction du titre depuis l'emploi du temps invalide"
) from exc
edt_ics_group_field = ScoDocSiteConfig.get("edt_ics_group_field")
edt_ics_group_regexp = ScoDocSiteConfig.get("edt_ics_group_regexp")
try:
edt_ics_group_pattern = (
re.compile(edt_ics_group_regexp) if edt_ics_group_regexp else None
)
except re.error as exc:
raise ScoValueError(
"expression d'extraction du groupe depuis l'emploi du temps invalide"
) from exc
edt_ics_mod_field = ScoDocSiteConfig.get("edt_ics_mod_field")
edt_ics_mod_regexp = ScoDocSiteConfig.get("edt_ics_mod_regexp")
try:
edt_ics_mod_pattern = (
re.compile(edt_ics_mod_regexp) if edt_ics_mod_regexp else None
)
except re.error as exc:
raise ScoValueError(
"expression d'extraction du module depuis l'emploi du temps invalide"
) from exc
# --- Correspondances id edt -> id scodoc pour groupes, modules et enseignants
edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre)
group_colors = {
group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1]
@ -93,45 +198,65 @@ def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]:
}
default_group = formsemestre.get_default_group()
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre)
# Chargement du calendier ics
calendar = formsemestre_load_calendar(formsemestre)
if not calendar:
return []
# Génération des événements, avec titre et champs utiles pour l'affichage dans ScoDoc
# ---
events = [e for e in calendar.walk() if e.name == "VEVENT"]
events_dict = []
events_sco = []
for event in events:
if "DESCRIPTION" in event:
# --- Titre de l'évènement
title = (
extract_event_data(event, edt_ics_title_field, edt_ics_title_pattern)
if edt_ics_title_pattern
else "non configuré"
)
# --- Group
edt_group = extract_event_group(event)
# si pas de groupe dans l'event, prend toute la promo ("tous")
group: GroupDescr = (
edt2group.get(edt_group, None) if edt_group else default_group
)
background_color = (
group_colors.get(edt_group, "rgb(214, 233, 248)")
if group
else "lightgrey"
)
group_disp = (
f"""<div class="group-name">{group.get_nom_with_part(default="promo")}</div>"""
if group
else f"""<div class="group-edt">{edt_group}
<span title="vérifier noms de groupe ou configuration extraction edt">
{scu.EMO_WARNING} non reconnu</span>
</div>"""
)
if edt_ics_group_pattern:
edt_group = extract_event_data(
event, edt_ics_group_field, edt_ics_group_pattern
)
# si pas de groupe dans l'event, oi si groupe non reconnu, prend toute la promo ("tous")
group: GroupDescr = (
edt2group.get(edt_group, default_group)
if edt_group
else default_group
)
group_bg_color = (
group_colors.get(edt_group, _EVENT_DEFAULT_COLOR)
if group
else "lightgrey"
)
else:
edt_group = ""
group = False
group_bg_color = _EVENT_DEFAULT_COLOR
# --- ModuleImpl
edt_module = extract_event_module(event)
modimpl: ModuleImpl = edt2modimpl.get(edt_module, None)
mod_disp = (
f"""<div class="module-edt mod-name" title="{modimpl.module.abbrev or ""}">{
modimpl.module.code}</div>"""
if modimpl
else f"""<div class="module-edt mod-etd" title="vérifier code edt module ?">{
scu.EMO_WARNING} {edt_module}</div>"""
if edt_ics_mod_pattern:
edt_module = extract_event_data(
event, edt_ics_mod_field, edt_ics_mod_pattern
)
modimpl: ModuleImpl = edt2modimpl.get(edt_module, None)
else:
modimpl = False
edt_module = ""
# --- TODO: enseignant
#
events_sco.append(
{
"title": title,
"edt_group": edt_group, # id group edt non traduit
"group": group, # False si extracteur non configuré
"group_bg_color": group_bg_color, # associée au groupe
"modimpl": modimpl, # False si extracteur non configuré
"edt_module": edt_module, # id module edt non traduit
"heure_deb": event.decoded("dtstart").strftime("%H:%M"),
"heure_fin": event.decoded("dtend").strftime("%H:%M"),
"jour": event.decoded("dtstart").isoformat(),
"start": event.decoded("dtstart").isoformat(),
"end": event.decoded("dtend").isoformat(),
}
)
<<<<<<< HEAD
# --- Lien saisie abs
link_abs = (
f"""<div class="module-edt link-abs"><a class="stdlink" href="{
@ -161,57 +286,73 @@ def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]:
events_dict.append(d)
return events_dict
=======
return events_sco
>>>>>>> edt
def extract_event_title(event: icalendar.cal.Event) -> str:
"""Extrait le titre à afficher dans nos calendriers (si on ne retrouve pas le module ScoDoc)
En effet, le titre présent dans l'ics emploi du temps est souvent complexe et peu parlant.
Par exemple, à l'USPN, Hyperplanning nous donne:
'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n'
"""
# TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ?
if not event.has_key("DESCRIPTION"):
def extract_event_data(
event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern
) -> str:
"""Extrait la chaine (id) de l'évènement."""
if not event.has_key(ics_field):
return "-"
description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8
# ici on prend le nom du module
m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description)
data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8
m = pattern.search(data)
if m and len(m.groups()) > 0:
return m.group(1)
# fallback: full description
return description
# fallback: ics field, complete
return data
def extract_event_module(event: icalendar.cal.Event) -> str:
"""Extrait le code module de l'emplois du temps.
Chaine vide si ne le trouve pas.
Par exemple, à l'USPN, Hyperplanning nous donne le code 'VRETR113' dans DESCRIPTION
'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n'
"""
# TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ?
if not event.has_key("DESCRIPTION"):
return "-"
description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8
# extraction du code:
m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description)
if m and len(m.groups()) > 0:
return m.group(1)
return ""
# def extract_event_title(event: icalendar.cal.Event) -> str:
# """Extrait le titre à afficher dans nos calendriers.
# En effet, le titre présent dans l'ics emploi du temps est souvent complexe et peu parlant.
# Par exemple, à l'USPN, Hyperplanning nous donne:
# 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n'
# """
# # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ?
# if not event.has_key("DESCRIPTION"):
# return "-"
# description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8
# # ici on prend le nom du module
# m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description)
# if m and len(m.groups()) > 0:
# return m.group(1)
# # fallback: full description
# return description
def extract_event_group(event: icalendar.cal.Event) -> str:
"""Extrait le nom du groupe (TD, ...). "" si pas de match."""
# TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ?
# Utilise ici le SUMMARY
# qui est de la forme
# SUMMARY;LANGUAGE=fr:TP2 GPR1 - VCYR303 - Services reseaux ava (VCYR303) - 1234 - M. VIENNET EMMANUEL - V2ROM - BUT2 RT pa. ROM - Groupe 1
if not event.has_key("SUMMARY"):
return "-"
summary = event.decoded("SUMMARY").decode("utf-8") # assume ics in utf8
# extraction du code:
m = re.search(r".*- ([\w\s]+)$", summary)
if m and len(m.groups()) > 0:
return m.group(1).strip()
return ""
# def extract_event_module(event: icalendar.cal.Event) -> str:
# """Extrait le code module de l'emplois du temps.
# Chaine vide si ne le trouve pas.
# Par exemple, à l'USPN, Hyperplanning nous donne le code 'VRETR113' dans DESCRIPTION
# 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n'
# """
# # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ?
# if not event.has_key("DESCRIPTION"):
# return "-"
# description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8
# # extraction du code:
# m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description)
# if m and len(m.groups()) > 0:
# return m.group(1)
# return ""
# def extract_event_group(event: icalendar.cal.Event) -> str:
# """Extrait le nom du groupe (TD, ...). "" si pas de match."""
# # Utilise ici le SUMMARY
# # qui est de la forme
# # SUMMARY;LANGUAGE=fr:TP2 GPR1 - VCYR303 - Services reseaux ava (VCYR303) - 1234 - M. VIENNET EMMANUEL - V2ROM - BUT2 RT pa. ROM - Groupe 1
# if not event.has_key("SUMMARY"):
# return "-"
# summary = event.decoded("SUMMARY").decode("utf-8") # assume ics in utf8
# # extraction du code:
# m = re.search(r".*- ([\w\s]+)$", summary)
# if m and len(m.groups()) > 0:
# return m.group(1).strip()
# return ""
def formsemestre_retreive_modimpls_from_edt_id(

View File

@ -214,6 +214,9 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
)
for index, resp in enumerate(formsemestre.responsables):
initvalues[resp_fields[index]] = uid2display.get(resp.id)
group_tous = formsemestre.get_default_group()
if group_tous:
initvalues["edt_promo_id"] = group_tous.edt_id or ""
# Liste des ID de semestres
if formation.type_parcours is not None:
@ -458,6 +461,18 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
},
)
)
modform.append(
(
"edt_promo_id",
{
"size": 32,
"title": "Identifiant EDT promo",
"explanation": """optionnel, identifiant du groupe "tous"
(promotion complète) dans l'emploi du temps.""",
"allow_null": True,
},
)
)
if edit:
formtit = f"""
<p><a class="stdlink" href="{url_for("notes.formsemestre_edit_uecoefs",
@ -970,8 +985,15 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
db.session.get(ApcParcours, int(parcour_id_str))
for parcour_id_str in tf[2]["parcours"]
]
# --- Id edt du groupe par défault
group_tous = formsemestre.get_default_group()
if group_tous:
group_tous.edt_id = tf[2]["edt_promo_id"]
db.session.add(group_tous)
db.session.add(formsemestre)
db.session.commit()
# --- Crée ou met à jour les groupes de parcours BUT
formsemestre.setup_parcours_groups()
# peut être nécessaire dans certains cas:

View File

@ -86,7 +86,10 @@ def group_rename(group_id):
"size": 12,
"allow_null": False,
"validator": lambda val, _: len(val) < GROUPNAME_STR_LEN,
"explanation": "doit être unique dans cette partition",
"explanation": "doit être unique dans cette partition"
if group.partition.groups_editable
else "groupes non modifiables dans cette partition",
"enabled": group.partition.groups_editable,
},
),
(
@ -97,7 +100,7 @@ def group_rename(group_id):
"size": 12,
"allow_null": True,
"explanation": """optionnel : identifiant du groupe dans le logiciel
d'emploi du temps, pour le cas où les noms de gropupes ne seraient pas
d'emploi du temps, pour le cas où les noms de groupes ne seraient pas
les mêmes dans ScoDoc et dans l'emploi du temps.""",
},
),
@ -123,6 +126,9 @@ def group_rename(group_id):
return flask.redirect(dest_url)
else:
# form submission
group.set_name(tf[2]["group_name"], edt_id=tf[2]["edt_id"], dest_url=dest_url)
# Si la partition n'est pas editable, on ne peut changer que l'edt_id
group.set_edt_id(tf[2]["edt_id"])
if group.partition.groups_editable:
group.set_name(tf[2]["group_name"], dest_url=dest_url)
flash("groupe modifié")
return flask.redirect(dest_url)

View File

@ -14,3 +14,14 @@
color: red;
background-color: yellow;
}
.toastui-calendar-timegrid {
height: 100% !important;
min-height: auto !important;
}
.toastui-calendar-time{
height: calc(100% - 44px) !important;
}
.toastui-calendar-week-view-day-names, .toastui-calendar-time {
overflow: hidden !important;
}

View File

@ -91,6 +91,10 @@ body:not(.editionActivated) .editing {
.nonEditable .editing {
display: none;
}
.nonEditable .editing.rename {
display: inline;
}
.editionActivated #zoneChoix,
.editionActivated #zoneGroupes {

View File

@ -1,6 +1,19 @@
{% extends "base.j2" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block styles %}
{{super()}}
<style>
div.config-section {
font-weight: bold;
font-size: 18px;
margin-right: -15px;
margin-left: -15px;
}
</style>
{% endblock %}
{% block app_content %}
<div class="row">
@ -33,6 +46,25 @@ affectent notamment les comptages d'absences de tous les bulletins des
{{ wtf.form_field(form.edt_ics_path) }}
</div>
<div class="config-section">Extraction des identifiants depuis les calendriers</div>
<div class="help">
Indiquer ici comment récupérer les informations (titre, groupe, module)
dans les calendriers publiés par votre logiciel d'emploi du temps.
</div>
<div class="config-edt">
{{ wtf.form_field(form.edt_ics_title_field) }}
{{ wtf.form_field(form.edt_ics_title_regexp) }}
</div>
<div class="config-edt">
{{ wtf.form_field(form.edt_ics_group_field) }}
{{ wtf.form_field(form.edt_ics_group_regexp) }}
</div>
<div class="config-edt">
{{ wtf.form_field(form.edt_ics_mod_field) }}
{{ wtf.form_field(form.edt_ics_mod_regexp) }}
</div>
<div class="form-group">
{{ wtf.form_field(form.submit) }}
{{ wtf.form_field(form.cancel) }}
@ -41,8 +73,4 @@ affectent notamment les comptages d'absences de tous les bulletins des
</div>
</form>
{% endblock %}

View File

@ -131,12 +131,12 @@
document.addEventListener(
"mouseup",
mouseUp,
{once:true}
{ once: true }
);
document.addEventListener(
"touchend",
mouseUp,
{once:true}
{ once: true }
);
} else if (event.target === periodTimeLine) {
@ -217,6 +217,14 @@
}
function setPeriodValues(deb, fin) {
if (fin < deb) {
throw new RangeError(`le paramètre 'deb' doit être inférieur au paramètre 'fin' ([${deb};${fin}])`)
}
if (deb < 0 || fin < 0) {
throw new RangeError(`Les paramètres doivent être des entiers positifis ([${deb};${fin}])`)
}
deb = snapToQuarter(deb);
fin = snapToQuarter(fin);
let leftPercentage = (deb - t_start) / (t_end - t_start) * 100;
@ -231,13 +239,13 @@
function snapHandlesToQuarters() {
const periodValues = getPeriodValues();
let lef = Math.min(computePercentage(periodValues[0], t_start), computePercentage(t_end, tick_delay));
let lef = Math.min(computePercentage(Math.abs(periodValues[0]), t_start), computePercentage(Math.abs(t_end), tick_delay));
if (lef < 0) {
lef = 0;
}
const left = `${lef}%`;
let wid = Math.max(computePercentage(periodValues[1], periodValues[0]), computePercentage(tick_delay, 0));
let wid = Math.max(computePercentage(Math.abs(periodValues[1]), Math.abs(periodValues[0])), computePercentage(tick_delay, 0));
if (wid > 100) {
wid = 100;
}
@ -251,10 +259,23 @@
function computePercentage(a, b) {
return ((a - b) / (t_end - t_start)) * 100;
}
function fromTime(time, separator = ":") {
const [hours, minutes] = time.split(separator).map((el) => Number(el))
return hours + minutes / 60
}
createTicks();
setPeriodValues(t_start, t_start + period_default);
{% if heures %}
let [heure_deb, heure_fin] = [{{ heures | safe }}]
if (heure_deb != '' && heure_fin != '') {
heure_deb = fromTime(heure_deb);
heure_fin = fromTime(heure_fin);
setPeriodValues(heure_deb, heure_fin)
}
{% endif %}
</script>
<style>
.timeline-container {

View File

@ -13,7 +13,7 @@
<div class="tab-content">
<h2>Expérimental: emploi du temps</h2>
<div id="calendar" style="height: 900px;"></div>
<div id="calendar" style="height: calc(100vh - 180px);"></div>
</div>
{% endblock app_content %}
@ -51,12 +51,19 @@ document.addEventListener('DOMContentLoaded', function() {
return `<strong>${start}</strong> <span>${event.title}</span>`;
},
},
timezone: {
zones: [
{
timezoneName: 'CET', // TODO récupérer timezone serveur
},
],
},
usageStatistics: false,
week: {
dayNames: [ "Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"],
eventView: ['time'],
hourStart: 7, // TODO préférence
hourEnd:24, // TODO préférence
hourStart: {{ hour_start }},
hourEnd: {{ hour_end }},
showNowIndicator: true,
startDayOfWeek: 1,
taskView: false,

View File

@ -251,7 +251,7 @@
div.innerHTML = `
<span class="editing move">||</span>
<span>${groupe.group_name} ${edt_id_str}</span>
<span class="editing"><a href="/ScoDoc/{{formsemestre.departement.acronym}}/Scolarite/group_rename?group_id=${groupe.id}">✏️</a></span>
<span class="editing rename"><a href="/ScoDoc/{{formsemestre.departement.acronym}}/Scolarite/group_rename?group_id=${groupe.id}">✏️</a></span>
<span class="editing suppr">❌</span>`;
div.addEventListener("click", filtre);

View File

@ -262,6 +262,10 @@ def signal_assiduites_etud():
# Récupération de la date (par défaut la date du jour)
date = request.args.get("date", datetime.date.today().isoformat())
heures: list[str] = [
request.args.get("heure_deb", ""),
request.args.get("heure_fin", ""),
]
# gestion évaluations (Appel à la page depuis les évaluations)
@ -320,7 +324,7 @@ def signal_assiduites_etud():
date=date,
morning=morning,
lunch=lunch,
timeline=_timeline(),
timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
afternoon=afternoon,
nonworkdays=_non_work_days(),
forcer_module=sco_preferences.get_preference(
@ -576,6 +580,10 @@ def signal_assiduites_group():
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
date: str = request.args.get("jour", datetime.date.today().isoformat())
heures: list[str] = [
request.args.get("heure_deb", ""),
request.args.get("heure_fin", ""),
]
group_ids: list[int] = request.args.get("group_ids", None)
if group_ids is None:
group_ids = []
@ -701,7 +709,7 @@ def signal_assiduites_group():
formsemestre_id=formsemestre_id,
grp=sco_groups_view.menu_groups_choice(groups_infos),
moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
timeline=_timeline(),
timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
nonworkdays=_non_work_days(),
formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin),
@ -1259,12 +1267,12 @@ def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
assiduites, metric=metrique, filtered={"split": True}
)
# On sépare :
# On sépare :
# - abs_j = absences justifiées
# - abs_nj = absences non justifiées
# - retards = les retards
# - justifs = les justificatifs
abs_j: list[str] = [
{"date": _get_date_str(assi.date_debut, assi.date_fin)}
for assi in assiduites
@ -1463,7 +1471,7 @@ def _dynamic_module_selector() -> str:
)
def _timeline(formsemestre_id: int = None) -> str:
def _timeline(formsemestre_id: int = None, heures=None) -> str:
"""
_timeline retourne l'html de la timeline
@ -1483,6 +1491,7 @@ def _timeline(formsemestre_id: int = None) -> str:
periode_defaut=sco_preferences.get_preference(
"periode_defaut", formsemestre_id
),
heures=heures,
)

View File

@ -38,7 +38,7 @@ from app.decorators import (
permission_required,
)
from app.forms.formsemestre import change_formation, edit_modimpls_codes_apo
from app.models import Formation, FormSemestre
from app.models import Formation, FormSemestre, ScoDocSiteConfig
from app.scodoc import sco_formations, sco_formation_versions
from app.scodoc.sco_permissions import Permission
from app.views import notes_bp as bp
@ -158,8 +158,14 @@ def formsemestre_edit_modimpls_codes(formsemestre_id: int):
def formsemestre_edt(formsemestre_id: int):
"""Expérimental: affiche emploi du temps du semestre"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
cfg = ScoDocSiteConfig.query.filter_by(name="assi_morning_time").first()
hour_start = cfg.value.split(":")[0].lstrip(" 0") if cfg else "7"
cfg = ScoDocSiteConfig.query.filter_by(name="assi_afternoon_time").first()
hour_end = cfg.value.split(":")[0].lstrip(" 0") if cfg else "18"
return render_template(
"formsemestre/edt.j2",
formsemestre=formsemestre,
hour_start=hour_start,
hour_end=hour_end,
sco=ScoData(formsemestre=formsemestre),
)

View File

@ -324,6 +324,16 @@ def config_assiduites():
if request.method == "POST" and form.cancel.data: # cancel button
return redirect(url_for("scodoc.index"))
edt_options = (
("edt_ics_path", "Chemin vers les calendriers ics"),
("edt_ics_title_field", "Champ contenant titre"),
("edt_ics_title_regexp", "Expression extraction titre"),
("edt_ics_group_field", "Champ contenant groupe"),
("edt_ics_group_regexp", "Expression extraction groupe"),
("edt_ics_mod_field", "Champ contenant module"),
("edt_ics_mod_regexp", "Expression extraction module"),
)
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")
@ -333,8 +343,11 @@ def config_assiduites():
flash("Heure de fin de la journée enregistrée")
if ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])):
flash("Granularité de la timeline enregistrée")
if ScoDocSiteConfig.set("edt_ics_path", form.data["edt_ics_path"]):
flash("Chemin vers les calendriers ics enregistré")
# --- Calendriers emploi du temps
for opt_name, message in edt_options:
if ScoDocSiteConfig.set(opt_name, form.data[opt_name]):
flash(f"{message} enregistré")
return redirect(url_for("scodoc.configuration"))
if request.method == "GET":
@ -352,7 +365,9 @@ def config_assiduites():
except ValueError:
form.tick_time.data = 15.0
ScoDocSiteConfig.set("assi_tick_time", 15.0)
form.edt_ics_path.data = ScoDocSiteConfig.get("edt_ics_path")
# --- Emplois du temps
for opt_name, _ in edt_options:
getattr(form, opt_name).data = ScoDocSiteConfig.get(opt_name)
return render_template(
"assiduites/pages/config_assiduites.j2",

View File

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