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 flask_wtf import FlaskForm
from wtforms import validators 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 from app.models import FormSemestre, ModuleImpl

View File

@ -29,6 +29,7 @@
Formulaire configuration Module Assiduités Formulaire configuration Module Assiduités
""" """
import datetime import datetime
import re
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError 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}") 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): class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité" "Formulaire paramétrage Module Assiduité"
@ -120,5 +138,45 @@ class ConfigAssiduitesForm(FlaskForm):
validators=[Optional(), check_ics_path], 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") submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) 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: def can_change_groups(self, user: User = None) -> bool:
"""Vrai si l'utilisateur (par def. current) peut changer les groupes dans """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: if not self.etat:
return False # semestre verrouillé return False # semestre verrouillé

View File

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

View File

@ -36,6 +36,7 @@ import icalendar
from flask import flash, g, url_for from flask import flash, g, url_for
from app import log from app import log
from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -67,6 +68,7 @@ def formsemestre_load_calendar(
return calendar return calendar
# --- Couleurs des évènements emploi du temps
_COLOR_PALETTE = [ _COLOR_PALETTE = [
"#ff6961", "#ff6961",
"#ffb480", "#ffb480",
@ -77,15 +79,118 @@ _COLOR_PALETTE = [
"#9d94ff", "#9d94ff",
"#c780e8", "#c780e8",
] ]
_EVENT_DEFAULT_COLOR = "rgb(214, 233, 248)"
def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]: def formsemestre_edt_dict(formsemestre: FormSemestre) -> list[dict]:
"""EDT complet du semestre, comme une liste de dict serialisable en json. """EDT complet du semestre, comme une liste de dict serialisable en json.
Fonction appellée par l'API /formsemestre/<int:formsemestre_id>/edt Fonction appellée par l'API /formsemestre/<int:formsemestre_id>/edt
TODO: spécifier intervalle de dates start et end 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) edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre)
group_colors = { group_colors = {
group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1] 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() default_group = formsemestre.get_default_group()
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) 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 = [e for e in calendar.walk() if e.name == "VEVENT"]
events_dict = [] events_sco = []
for event in events: for event in events:
if "DESCRIPTION" in event: 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 # --- Group
edt_group = extract_event_group(event) if edt_ics_group_pattern:
# si pas de groupe dans l'event, prend toute la promo ("tous") edt_group = extract_event_data(
group: GroupDescr = ( event, edt_ics_group_field, edt_ics_group_pattern
edt2group.get(edt_group, None) if edt_group else default_group )
) # si pas de groupe dans l'event, oi si groupe non reconnu, prend toute la promo ("tous")
background_color = ( group: GroupDescr = (
group_colors.get(edt_group, "rgb(214, 233, 248)") edt2group.get(edt_group, default_group)
if group if edt_group
else "lightgrey" else default_group
) )
group_disp = ( group_bg_color = (
f"""<div class="group-name">{group.get_nom_with_part(default="promo")}</div>""" group_colors.get(edt_group, _EVENT_DEFAULT_COLOR)
if group if group
else f"""<div class="group-edt">{edt_group} else "lightgrey"
<span title="vérifier noms de groupe ou configuration extraction edt"> )
{scu.EMO_WARNING} non reconnu</span> else:
</div>""" edt_group = ""
) group = False
group_bg_color = _EVENT_DEFAULT_COLOR
# --- ModuleImpl # --- ModuleImpl
edt_module = extract_event_module(event) if edt_ics_mod_pattern:
modimpl: ModuleImpl = edt2modimpl.get(edt_module, None) edt_module = extract_event_data(
mod_disp = ( event, edt_ics_mod_field, edt_ics_mod_pattern
f"""<div class="module-edt mod-name" title="{modimpl.module.abbrev or ""}">{ )
modimpl.module.code}</div>""" modimpl: ModuleImpl = edt2modimpl.get(edt_module, None)
if modimpl else:
else f"""<div class="module-edt mod-etd" title="vérifier code edt module ?">{ modimpl = False
scu.EMO_WARNING} {edt_module}</div>""" 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 # --- Lien saisie abs
link_abs = ( link_abs = (
f"""<div class="module-edt link-abs"><a class="stdlink" href="{ 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) events_dict.append(d)
return events_dict return events_dict
=======
return events_sco
>>>>>>> edt
def extract_event_title(event: icalendar.cal.Event) -> str: def extract_event_data(
"""Extrait le titre à afficher dans nos calendriers (si on ne retrouve pas le module ScoDoc) event: icalendar.cal.Event, ics_field: str, pattern: re.Pattern
En effet, le titre présent dans l'ics emploi du temps est souvent complexe et peu parlant. ) -> str:
Par exemple, à l'USPN, Hyperplanning nous donne: """Extrait la chaine (id) de l'évènement."""
'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n' if not event.has_key(ics_field):
"""
# 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 "-" return "-"
description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 data = event.decoded(ics_field).decode("utf-8") # assume ics in utf8
# ici on prend le nom du module m = pattern.search(data)
m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description)
if m and len(m.groups()) > 0: if m and len(m.groups()) > 0:
return m.group(1) return m.group(1)
# fallback: full description # fallback: ics field, complete
return description return data
def extract_event_module(event: icalendar.cal.Event) -> str: # def extract_event_title(event: icalendar.cal.Event) -> str:
"""Extrait le code module de l'emplois du temps. # """Extrait le titre à afficher dans nos calendriers.
Chaine vide si ne le trouve pas. # 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 le code 'VRETR113' dans DESCRIPTION # 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' # '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 ? # # 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"): # if not event.has_key("DESCRIPTION"):
return "-" # return "-"
description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8 # description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8
# extraction du code: # # ici on prend le nom du module
m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description) # m = re.search(r"Matière : \w+ - ([\w\.\s']+)", description)
if m and len(m.groups()) > 0: # if m and len(m.groups()) > 0:
return m.group(1) # return m.group(1)
return "" # # fallback: full description
# return description
def extract_event_group(event: icalendar.cal.Event) -> str: # def extract_event_module(event: icalendar.cal.Event) -> str:
"""Extrait le nom du groupe (TD, ...). "" si pas de match.""" # """Extrait le code module de l'emplois du temps.
# TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ? # Chaine vide si ne le trouve pas.
# Utilise ici le SUMMARY # Par exemple, à l'USPN, Hyperplanning nous donne le code 'VRETR113' dans DESCRIPTION
# qui est de la forme # 'Matière : VRETR113 - Mathematiques du sig (VRETR113\nEnseignant : 1234 - M. DUPONT PIERRE\nTD : TDB\nSalle : L112 (IUTV) - L112\n'
# 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"): # # TODO: fonction ajustée à l'USPN, devra être paramétrable d'une façon ou d'une autre: regexp ?
return "-" # if not event.has_key("DESCRIPTION"):
summary = event.decoded("SUMMARY").decode("utf-8") # assume ics in utf8 # return "-"
# extraction du code: # description = event.decoded("DESCRIPTION").decode("utf-8") # assume ics in utf8
m = re.search(r".*- ([\w\s]+)$", summary) # # extraction du code:
if m and len(m.groups()) > 0: # m = re.search(r"Matière : ([A-Z][A-Z0-9]+)", description)
return m.group(1).strip() # if m and len(m.groups()) > 0:
return "" # 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( 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): for index, resp in enumerate(formsemestre.responsables):
initvalues[resp_fields[index]] = uid2display.get(resp.id) 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 # Liste des ID de semestres
if formation.type_parcours is not None: 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: if edit:
formtit = f""" formtit = f"""
<p><a class="stdlink" href="{url_for("notes.formsemestre_edit_uecoefs", <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)) db.session.get(ApcParcours, int(parcour_id_str))
for parcour_id_str in tf[2]["parcours"] 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.add(formsemestre)
db.session.commit() db.session.commit()
# --- Crée ou met à jour les groupes de parcours BUT # --- Crée ou met à jour les groupes de parcours BUT
formsemestre.setup_parcours_groups() formsemestre.setup_parcours_groups()
# peut être nécessaire dans certains cas: # peut être nécessaire dans certains cas:

View File

@ -86,7 +86,10 @@ def group_rename(group_id):
"size": 12, "size": 12,
"allow_null": False, "allow_null": False,
"validator": lambda val, _: len(val) < GROUPNAME_STR_LEN, "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, "size": 12,
"allow_null": True, "allow_null": True,
"explanation": """optionnel : identifiant du groupe dans le logiciel "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.""", 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) return flask.redirect(dest_url)
else: else:
# form submission # 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é") flash("groupe modifié")
return flask.redirect(dest_url) return flask.redirect(dest_url)

View File

@ -14,3 +14,14 @@
color: red; color: red;
background-color: yellow; 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 { .nonEditable .editing {
display: none; display: none;
} }
.nonEditable .editing.rename {
display: inline;
}
.editionActivated #zoneChoix, .editionActivated #zoneChoix,
.editionActivated #zoneGroupes { .editionActivated #zoneGroupes {

View File

@ -1,6 +1,19 @@
{% extends "base.j2" %} {% extends "base.j2" %}
{% import 'bootstrap/wtf.html' as wtf %} {% 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 %} {% block app_content %}
<div class="row"> <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) }} {{ wtf.form_field(form.edt_ics_path) }}
</div> </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"> <div class="form-group">
{{ wtf.form_field(form.submit) }} {{ wtf.form_field(form.submit) }}
{{ wtf.form_field(form.cancel) }} {{ wtf.form_field(form.cancel) }}
@ -41,8 +73,4 @@ affectent notamment les comptages d'absences de tous les bulletins des
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -131,12 +131,12 @@
document.addEventListener( document.addEventListener(
"mouseup", "mouseup",
mouseUp, mouseUp,
{once:true} { once: true }
); );
document.addEventListener( document.addEventListener(
"touchend", "touchend",
mouseUp, mouseUp,
{once:true} { once: true }
); );
} else if (event.target === periodTimeLine) { } else if (event.target === periodTimeLine) {
@ -217,6 +217,14 @@
} }
function setPeriodValues(deb, fin) { 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); deb = snapToQuarter(deb);
fin = snapToQuarter(fin); fin = snapToQuarter(fin);
let leftPercentage = (deb - t_start) / (t_end - t_start) * 100; let leftPercentage = (deb - t_start) / (t_end - t_start) * 100;
@ -231,13 +239,13 @@
function snapHandlesToQuarters() { function snapHandlesToQuarters() {
const periodValues = getPeriodValues(); 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) { if (lef < 0) {
lef = 0; lef = 0;
} }
const left = `${lef}%`; 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) { if (wid > 100) {
wid = 100; wid = 100;
} }
@ -251,10 +259,23 @@
function computePercentage(a, b) { function computePercentage(a, b) {
return ((a - b) / (t_end - t_start)) * 100; 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(); createTicks();
setPeriodValues(t_start, t_start + period_default); 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> </script>
<style> <style>
.timeline-container { .timeline-container {

View File

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

View File

@ -251,7 +251,7 @@
div.innerHTML = ` div.innerHTML = `
<span class="editing move">||</span> <span class="editing move">||</span>
<span>${groupe.group_name} ${edt_id_str}</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>`; <span class="editing suppr">❌</span>`;
div.addEventListener("click", filtre); 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) # Récupération de la date (par défaut la date du jour)
date = request.args.get("date", datetime.date.today().isoformat()) 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) # gestion évaluations (Appel à la page depuis les évaluations)
@ -320,7 +324,7 @@ def signal_assiduites_etud():
date=date, date=date,
morning=morning, morning=morning,
lunch=lunch, lunch=lunch,
timeline=_timeline(), timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
afternoon=afternoon, afternoon=afternoon,
nonworkdays=_non_work_days(), nonworkdays=_non_work_days(),
forcer_module=sco_preferences.get_preference( forcer_module=sco_preferences.get_preference(
@ -576,6 +580,10 @@ def signal_assiduites_group():
formsemestre_id: int = request.args.get("formsemestre_id", -1) formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id") moduleimpl_id: int = request.args.get("moduleimpl_id")
date: str = request.args.get("jour", datetime.date.today().isoformat()) 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) group_ids: list[int] = request.args.get("group_ids", None)
if group_ids is None: if group_ids is None:
group_ids = [] group_ids = []
@ -701,7 +709,7 @@ def signal_assiduites_group():
formsemestre_id=formsemestre_id, formsemestre_id=formsemestre_id,
grp=sco_groups_view.menu_groups_choice(groups_infos), grp=sco_groups_view.menu_groups_choice(groups_infos),
moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
timeline=_timeline(), timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
nonworkdays=_non_work_days(), nonworkdays=_non_work_days(),
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),
@ -1259,12 +1267,12 @@ def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
assiduites, metric=metrique, filtered={"split": True} assiduites, metric=metrique, filtered={"split": True}
) )
# On sépare : # On sépare :
# - abs_j = absences justifiées # - abs_j = absences justifiées
# - abs_nj = absences non justifiées # - abs_nj = absences non justifiées
# - retards = les retards # - retards = les retards
# - justifs = les justificatifs # - justifs = les justificatifs
abs_j: list[str] = [ abs_j: list[str] = [
{"date": _get_date_str(assi.date_debut, assi.date_fin)} {"date": _get_date_str(assi.date_debut, assi.date_fin)}
for assi in assiduites 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 _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=sco_preferences.get_preference(
"periode_defaut", formsemestre_id "periode_defaut", formsemestre_id
), ),
heures=heures,
) )

View File

@ -38,7 +38,7 @@ from app.decorators import (
permission_required, permission_required,
) )
from app.forms.formsemestre import change_formation, edit_modimpls_codes_apo 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 import sco_formations, sco_formation_versions
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.views import notes_bp as bp 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): def formsemestre_edt(formsemestre_id: int):
"""Expérimental: affiche emploi du temps du semestre""" """Expérimental: affiche emploi du temps du semestre"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id) 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( return render_template(
"formsemestre/edt.j2", "formsemestre/edt.j2",
formsemestre=formsemestre, formsemestre=formsemestre,
hour_start=hour_start,
hour_end=hour_end,
sco=ScoData(formsemestre=formsemestre), sco=ScoData(formsemestre=formsemestre),
) )

View File

@ -324,6 +324,16 @@ def config_assiduites():
if request.method == "POST" and form.cancel.data: # cancel button if request.method == "POST" and form.cancel.data: # cancel button
return redirect(url_for("scodoc.index")) 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 form.validate_on_submit():
if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]): if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]):
flash("Heure du début de la journée enregistrée") 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") flash("Heure de fin de la journée enregistrée")
if ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])): if ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"])):
flash("Granularité de la timeline enregistrée") flash("Granularité de la timeline enregistrée")
if ScoDocSiteConfig.set("edt_ics_path", form.data["edt_ics_path"]): # --- Calendriers emploi du temps
flash("Chemin vers les calendriers ics enregistré") 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")) return redirect(url_for("scodoc.configuration"))
if request.method == "GET": if request.method == "GET":
@ -352,7 +365,9 @@ def config_assiduites():
except ValueError: except ValueError:
form.tick_time.data = 15.0 form.tick_time.data = 15.0
ScoDocSiteConfig.set("assi_tick_time", 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( return render_template(
"assiduites/pages/config_assiduites.j2", "assiduites/pages/config_assiduites.j2",

View File

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