WIP: Affichage de l'emploi du temps du semestre.

This commit is contained in:
Emmanuel Viennet 2023-11-11 18:13:18 +01:00
parent e06292de99
commit d1bc546d7b
17 changed files with 393 additions and 188 deletions

View File

@ -33,6 +33,7 @@ from app.models import (
)
from app.models.formsemestre import GROUPS_AUTO_ASSIGNMENT_DATA_MAX
from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json
from app.scodoc import sco_edt_cal
from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import ModuleType
@ -555,3 +556,18 @@ def save_groups_auto_assignment(formsemestre_id: int):
formsemestre.groups_auto_assignment_data = request.data
db.session.add(formsemestre)
db.session.commit()
@bp.route("/formsemestre/<int:formsemestre_id>/edt")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/edt")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_edt(formsemestre_id: int):
"""l'emploi du temps du semestre"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
return sco_edt_cal.formsemestre_edt_dict(formsemestre)

View File

@ -259,6 +259,22 @@ class FormSemestre(db.Model):
d["session_id"] = self.session_id()
return d
def get_default_group(self) -> GroupDescr:
"""default ('tous') group.
Le groupe par défaut contient tous les étudiants et existe toujours.
C'est l'unique groupe de la partition sans nom.
"""
default_partition = self.partitions.filter_by(partition_name=None).first()
if default_partition:
return default_partition.groups.first()
raise ScoValueError("Le semestre n'a pas de groupe par défaut")
def get_edt_id(self) -> str:
"l'id pour l'emploi du temps: à défaut, le 1er code étape Apogée"
return (
self.edt_id or "" or (self.etapes[0].etape_apo if len(self.etapes) else "")
)
def get_infos_dict(self) -> dict:
"""Un dict avec des informations sur le semestre
pour les bulletins et autres templates

View File

@ -231,8 +231,12 @@ class GroupDescr(db.Model):
f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">"""
)
def get_nom_with_part(self) -> str:
"Nom avec partition: 'TD A'"
def get_nom_with_part(self, default="-") -> str:
"""Nom avec partition: 'TD A'
Si groupe par défaut (tous), utilise default ou "-"
"""
if self.partition.partition_name is None:
return default
return f"{self.partition.partition_name or ''} {self.group_name or '-'}"
def to_dict(self, with_partition=True) -> dict:
@ -243,10 +247,14 @@ class GroupDescr(db.Model):
d["partition"] = self.partition.to_dict(with_groups=False)
return d
def get_edt_id(self) -> str:
"l'id pour l'emploi du temps: à défaut, le nom scodoc du groupe"
return self.edt_id or self.group_name or ""
def get_nb_inscrits(self) -> int:
"""Nombre inscrits à ce group et au formsemestre.
C'est nécessaire car lors d'une désinscription, on conserve l'appartenance
aux groupes pour facilier une éventuelle -inscription.
aux groupes pour faciliter une éventuelle -inscription.
"""
from app.models.formsemestre import FormSemestreInscription

View File

@ -45,6 +45,12 @@ class ModuleImpl(db.Model):
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>"
def get_edt_id(self) -> str:
"l'id pour l'emploi du temps: actuellement celui du module"
return (
self.module.get_edt_id()
) # TODO à décliner pour autoriser des codes différents ?
def get_evaluations_poids(self) -> pd.DataFrame:
"""Les poids des évaluations vers les UE (accès via cache)"""
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)

View File

@ -285,6 +285,14 @@ class Module(db.Model):
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def get_edt_id(self) -> str:
"l'id pour l'emploi du temps: à défaut, le 1er code Apogée"
return (
self.edt_id
or (self.code_apogee.split(",")[0] if self.code_apogee else "")
or ""
)
def get_parcours(self) -> list[ApcParcours]:
"""Les parcours utilisant ce module.
Si tous les parcours, liste vide (!).

View File

@ -29,175 +29,191 @@
XXX usage uniquement experimental pour tests implémentations
XXX incompatible avec les ics HyperPlanning Paris 13 (était pour GPU).
"""
import re
import icalendar
import urllib
import app.scodoc.sco_utils as scu
from flask import flash
from app import log
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
from app.scodoc import sco_preferences
from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig
import app.scodoc.sco_utils as scu
def formsemestre_get_ics_url(sem):
"""
edt_sem_ics_url est un template
utilisé avec .format(sem=sem)
Par exemple:
https://example.fr/agenda/{sem[etapes][0]}
"""
ics_url_tmpl = sco_preferences.get_preference(
"edt_sem_ics_url", sem["formsemestre_id"]
def formsemestre_load_calendar(
formsemestre: FormSemestre,
) -> icalendar.cal.Calendar | None:
"""Load ics data, return calendar or None if not configured or not available"""
edt_id = formsemestre.get_edt_id()
if not edt_id:
flash(
"accès aux emplois du temps non configuré pour ce semestre (pas d'edt_id)"
)
if not ics_url_tmpl:
return None
edt_ics_path = ScoDocSiteConfig.get("edt_ics_path")
if not edt_ics_path.strip():
return None
ics_filename = edt_ics_path.format(edt_id=edt_id)
try:
ics_url = ics_url_tmpl.format(sem=sem)
except:
with open(ics_filename, "rb") as file:
log(f"Loading edt from {ics_filename}")
calendar = icalendar.Calendar.from_ical(file.read())
except FileNotFoundError:
flash("erreur chargement du calendrier")
log(
f"""Exception in formsemestre_get_ics_url(formsemestre_id={sem["formsemestre_id"]})
ics_url_tmpl='{ics_url_tmpl}'
"""
f"formsemestre_load_calendar: ics not found for {formsemestre}\npath='{ics_filename}'"
)
log(traceback.format_exc())
return None
return ics_url
return calendar
def formsemestre_load_ics(sem):
"""Load ics data, from our cache or, when necessary, from external provider"""
# TODO: cacher le résultat
ics_url = formsemestre_get_ics_url(sem)
if not ics_url:
ics_data = ""
else:
log(f"Loading edt from {ics_url}")
# 5s TODO: add config parameter, eg for slow networks
f = urllib.request.urlopen(ics_url, timeout=5)
ics_data = f.read()
f.close()
cal = icalendar.Calendar.from_ical(ics_data)
return cal
_COLOR_PALETTE = [
"#ff6961",
"#ffb480",
"#f8f38d",
"#42d6a4",
"#08cad1",
"#59adf6",
"#9d94ff",
"#c780e8",
]
def get_edt_transcodage_groups(formsemestre_id):
"""-> { nom_groupe_edt : nom_groupe_scodoc }"""
# TODO: valider ces données au moment où on enregistre les préférences
edt2sco = {}
sco2edt = {}
msg = "" # message erreur, '' si ok
txt = sco_preferences.get_preference("edt_groups2scodoc", formsemestre_id)
if not txt:
return edt2sco, sco2edt, msg
line_num = 1
for line in txt.split("\n"):
fs = [s.strip() for s in line.split(";")]
if len(fs) == 1: # groupe 'tous'
edt2sco[fs[0]] = None
sco2edt[None] = fs[0]
elif len(fs) == 2:
edt2sco[fs[0]] = fs[1]
sco2edt[fs[1]] = fs[0]
else:
msg = f"ligne {line_num} invalide"
line_num += 1
log(f"sco2edt={pprint.pformat(sco2edt)}")
return edt2sco, sco2edt, msg
def group_edt_json(group_id, start="", end=""): # actuellement inutilisé
"""EDT complet du semestre, au format JSON
TODO: indiquer un groupe
TODO: utiliser start et end (2 dates au format ISO YYYY-MM-DD)
TODO: cacher
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 ?
"""
group = sco_groups.get_group(group_id)
sem = sco_formsemestre.get_formsemestre(group["formsemestre_id"])
edt2sco, sco2edt, msg = get_edt_transcodage_groups(group["formsemestre_id"])
edt_group_name = sco2edt.get(group["group_name"], group["group_name"])
log("group scodoc=%s : edt=%s" % (group["group_name"], edt_group_name))
cal = formsemestre_load_ics(sem)
events = [e for e in cal.walk() if e.name == "VEVENT"]
J = []
for e in events:
# if e['X-GROUP-ID'].strip() == edt_group_name:
if "DESCRIPTION" in e:
d = {
"title": e.decoded("DESCRIPTION"), # + '/' + e['X-GROUP-ID'],
"start": e.decoded("dtstart").isoformat(),
"end": e.decoded("dtend").isoformat(),
# 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]
for i, group_name in enumerate(edt2group)
}
J.append(d)
default_group = formsemestre.get_default_group()
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre)
return scu.sendJSON(J)
# 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 = []
for event in events:
if "DESCRIPTION" in event:
# --- 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>"""
)
# --- 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>"""
)
d = {
# Champs utilisés par tui.calendar
"calendarId": "cal1",
"title": extract_event_title(event) + group_disp + mod_disp,
"start": event.decoded("dtstart").isoformat(),
"end": event.decoded("dtend").isoformat(),
"backgroundColor": background_color,
# Infos brutes pour usage API éventuel
"group_id": group.id if group else None,
"group_edt_id": edt_group,
"moduleimpl_id": modimpl.id if modimpl else None,
}
events_dict.append(d)
return events_dict
# def experimental_calendar(group_id=None, formsemestre_id=None): # inutilisé
# """experimental page"""
# return "\n".join(
# [
# html_sco_header.sco_header(
# javascripts=[
# "libjs/purl.js",
# "libjs/moment.min.js",
# "libjs/fullcalendar/fullcalendar.min.js",
# ],
# cssstyles=[
# # 'libjs/bootstrap-3.1.1-dist/css/bootstrap.min.css',
# # 'libjs/bootstrap-3.1.1-dist/css/bootstrap-theme.min.css',
# # 'libjs/bootstrap-multiselect/bootstrap-multiselect.css'
# "libjs/fullcalendar/fullcalendar.css",
# # media='print' 'libjs/fullcalendar/fullcalendar.print.css'
# ],
# ),
# """<style>
# #loading {
# display: none;
# position: absolute;
# top: 10px;
# right: 10px;
# }
# </style>
# """,
# """<form id="group_selector" method="get">
# <span style="font-weight: bold; font-size:120%">Emplois du temps du groupe</span>""",
# sco_groups_view.menu_group_choice(
# group_id=group_id, formsemestre_id=formsemestre_id
# ),
# """</form><div id="loading">loading...</div>
# <div id="calendar"></div>
# """,
# html_sco_header.sco_footer(),
# """<script>
# $(document).ready(function() {
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"):
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
# var group_id = $.url().param()['group_id'];
# $('#calendar').fullCalendar({
# events: {
# url: 'group_edt_json?group_id=' + group_id,
# error: function() {
# $('#script-warning').show();
# }
# },
# timeFormat: 'HH:mm',
# timezone: 'local', // heure locale du client
# loading: function(bool) {
# $('#loading').toggle(bool);
# }
# });
# });
# </script>
# """,
# ]
# )
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."""
# 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 formsemestre_retreive_modimpls_from_edt_id(
formsemestre: FormSemestre,
) -> dict[str, ModuleImpl]:
"""Construit un dict donnant le moduleimpl de chaque edt_id"""
edt2modimpl = {modimpl.get_edt_id(): modimpl for modimpl in formsemestre.modimpls}
edt2modimpl.pop("", None)
return edt2modimpl
def formsemestre_retreive_groups_from_edt_id(
formsemestre: FormSemestre,
) -> dict[str, GroupDescr]:
"""Construit un dict donnant le groupe de chaque edt_id"""
edt2group = {}
for partition in formsemestre.partitions:
edt2group.update({g.get_edt_id(): g for g in partition.groups})
edt2group.pop("", None)
return edt2group

View File

@ -86,11 +86,6 @@ def build_context_dict(formsemestre_id: int) -> dict:
def formsemestre_custommenu_html(formsemestre_id):
"HTML code for custom menu"
menu = []
# Calendrier électronique ?
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
ics_url = sco_edt_cal.formsemestre_get_ics_url(sem)
if ics_url:
menu.append({"title": "Emploi du temps (ics)", "url": ics_url})
# Liens globaux (config. générale)
params = build_context_dict(formsemestre_id)
for link in ScoDocSiteConfig.get_perso_links():

View File

@ -257,6 +257,13 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
"enabled": current_user.has_permission(Permission.EditFormSemestre),
"helpmsg": "",
},
{
"title": "Expérimental: emploi du temps",
"endpoint": "notes.formsemestre_edt",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "",
},
]
# debug :
if current_app.config["DEBUG"]:

View File

@ -208,8 +208,10 @@ def get_partition_groups(partition): # OBSOLETE !
)
def get_default_group(formsemestre_id, fix_if_missing=False):
"""Returns group_id for default ('tous') group"""
def get_default_group(formsemestre_id, fix_if_missing=False) -> int:
"""Returns group_id for default ('tous') group
XXX remplacé par formsemestre.get_default_group
"""
r = ndb.SimpleDictFetch(
"""SELECT gd.id AS group_id
FROM group_descr gd, partition p

View File

@ -96,11 +96,13 @@ def group_rename(group_id):
"default": group.edt_id or "",
"size": 12,
"allow_null": True,
"explanation": "optionnel : identifiant du groupe dans le logiciel d'emploi du temps",
"explanation": """optionnel : identifiant du groupe dans le logiciel
d'emploi du temps, pour le cas où les noms de gropupes ne seraient pas
les mêmes dans ScoDoc et dans l'emploi du temps.""",
},
),
),
submitlabel="Renommer",
submitlabel="Enregistrer",
cancelbutton="Annuler",
)
dest_url = url_for(

View File

@ -2033,24 +2033,13 @@ class BasePreferences:
"category": "edt",
},
),
(
"edt_groups2scodoc",
{
"input_type": "textarea",
"initvalue": "",
"title": "Noms Groupes",
"explanation": "Transcodage: nom de groupe EDT ; non de groupe ScoDoc (sur plusieurs lignes)",
"rows": 8,
"cols": 16,
"category": "edt",
},
),
# Divers
(
"ImputationDept",
{
"title": "Département d'imputation",
"initvalue": "",
"explanation": "préfixe id de session (optionnel, remplace nom département)",
"explanation": "optionnel: préfixe id de formsemestre (par défaut, le nom du département). Pour usages API avancés.",
"size": 10,
"category": "edt",
},

16
app/static/css/edt.css Normal file
View File

@ -0,0 +1,16 @@
.toastui-calendar-template-time {
padding: 4px;
word-break: break-all;
white-space: normal !important;
align-items: normal !important;
font-size: 12pt;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.group-name {
color:rgb(25, 113, 25);
}
.group-edt {
color: red;
background-color: yellow;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,96 @@
{% extends "sco_page.j2" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block styles %}
{{super()}}
<link href="{{scu.STATIC_DIR}}/libjs/tui.calendar/toastui-calendar.min.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="{{ scu.STATIC_DIR }}/css/edt.css" type="text/css">
{% endblock %}
{% block app_content %}
<div class="tab-content">
<h2>Expérimental: emploi du temps</h2>
<div id="calendar" style="height: 900px;"></div>
</div>
{% endblock app_content %}
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/libjs/tui.calendar/toastui-calendar.min.js"></script>
<script>
let hm_formatter = new Intl.DateTimeFormat('default', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
document.addEventListener('DOMContentLoaded', function() {
const Calendar = tui.Calendar;
const container = document.getElementById('calendar');
const options = {
defaultView: 'week',
calendars: [
{
id: 'cal1',
name: 'Personal',
backgroundColor: '#03bd9e',
borderColor: 'white',
},
],
isReadOnly: true,
// timezone: { zones: [ { timezoneName: 'Europe/Paris' } ] },
template: {
// ce template nous permet d'avoir du HTML dans le title de l'event
time: function(event) {
const date_start = new Date(event.start);
const start = hm_formatter.format(date_start);
return `<strong>${start}</strong> <span>${event.title}</span>`;
},
},
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
showNowIndicator: true,
startDayOfWeek: 1,
taskView: false,
useDetailPopup:false, // on va pouvoir placer les liens scodoc
workweek: true, // TODO voir samedi travaillé
},
};
const calendar = new Calendar(container, options);
//let events = [
// {
// id: "12456",
// start:"2023-11-10T09:30",
// end:"2023-11-10T11:30",
// backgroundColor:"lightblue",
// color: "red", // couleur du texte
// location: "quelque part",
// title:'Essai <a href="">saisir</a>',
// },
// {
// id: "12457",
// start:"2023-11-10T09:30",
// end:"2023-11-10T11:50",
// backgroundColor:"lightgreen",
// color: "blue", // couleur du texte
// title:'TD groupe 2',
// },
//];
fetch(`${SCO_URL}/../api/formsemestre/{{formsemestre.id}}/edt`)
.then(r=>{return r.json()})
.then(events=>{
calendar.createEvents(events);
});
});
</script>
{% endblock scripts %}

View File

@ -105,3 +105,16 @@ def formsemestre_change_formation(formsemestre_id: int):
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
)
@bp.route("/formsemestre/edt/<int:formsemestre_id>")
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_edt(formsemestre_id: int):
"""Expérimental: affiche emploi du temps du semestre"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
return render_template(
"formsemestre/edt.j2",
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
)

View File

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