WIP: tableau de bord utilisateur

This commit is contained in:
Emmanuel Viennet 2023-12-29 13:58:18 +01:00
parent a6448192a6
commit ae94d8fba4
9 changed files with 230 additions and 59 deletions

3
.gitignore vendored
View File

@ -176,3 +176,6 @@ copy
# Symlinks static ScoDoc # Symlinks static ScoDoc
app/static/links/[0-9]*.*[0-9] app/static/links/[0-9]*.*[0-9]
# Essais locaux
xp/

View File

@ -10,8 +10,10 @@
"""ScoDoc models: formsemestre """ScoDoc models: formsemestre
""" """
from collections import defaultdict
import datetime import datetime
from functools import cached_property from functools import cached_property
from itertools import chain
from operator import attrgetter from operator import attrgetter
from flask_login import current_user from flask_login import current_user
@ -35,7 +37,11 @@ from app.models.etudiants import Identite
from app.models.evaluations import Evaluation from app.models.evaluations import Evaluation
from app.models.formations import Formation from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription from app.models.moduleimpls import (
ModuleImpl,
ModuleImplInscription,
notes_modules_enseignants,
)
from app.models.modules import Module from app.models.modules import Module
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation from app.models.validations import ScolarFormSemestreValidation
@ -45,8 +51,6 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.sco_utils import translate_assiduites_metric
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
@ -380,6 +384,73 @@ class FormSemestre(db.Model):
_cache[key] = ues _cache[key] = ues
return ues return ues
@classmethod
def get_user_formsemestres_annee(
cls, user: User
) -> tuple[list["FormSemestre"], defaultdict[int, list[ModuleImpl]]]:
"""Liste des formsemestres de l'année scolaire
dans lesquels user intervient (comme resp., resp. de module ou enseignant),
ainsi que la liste des modimpls concernés dans chaque formsemestre
Attention: les semestres et modimpls peuvent être de différents départements !
"""
debut_annee_scolaire = scu.date_debut_annee_scolaire()
fin_annee_scolaire = scu.date_fin_annee_scolaire()
query = FormSemestre.query.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
# responsable ?
formsemestres_resp = query.join(notes_formsemestre_responsables).filter_by(
responsable_id=user.id
)
# Responsable d'un modimpl ?
modimpls_resp = (
ModuleImpl.query.filter_by(responsable_id=user.id)
.join(FormSemestre)
.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
)
# Enseignant dans un modimpl ?
modimpls_ens = (
ModuleImpl.query.join(notes_modules_enseignants)
.filter_by(ens_id=user.id)
.join(FormSemestre)
.filter(
FormSemestre.date_fin >= debut_annee_scolaire,
FormSemestre.date_debut < fin_annee_scolaire,
)
)
# Liste les modimpls, uniques
modimpls = modimpls_resp.all()
ids = {modimpl.id for modimpl in modimpls}
for modimpl in modimpls_ens:
if modimpl.id not in ids:
modimpls.append(modimpl)
ids.add(modimpl.id)
# Liste les formsemestres et modimpls associés
modimpls_by_formsemestre = defaultdict(lambda: [])
formsemestres = formsemestres_resp.all()
ids = {formsemestre.id for formsemestre in formsemestres}
for modimpl in chain(modimpls_resp, modimpls_ens):
if modimpl.formsemestre_id not in ids:
formsemestres.append(modimpl.formsemestre)
ids.add(modimpl.formsemestre_id)
modimpls_by_formsemestre[modimpl.formsemestre_id].append(modimpl)
# Tris
formsemestres.sort(key=lambda x: (x.departement.acronym,) + x.sort_key())
for formsemestre in formsemestres:
modimpls = modimpls_by_formsemestre[formsemestre.id]
if formsemestre.formation.is_apc():
key = lambda x: x.module.sort_key_apc()
else:
key = lambda x: x.module.sort_key()
modimpls.sort(key=key)
return formsemestres, modimpls_by_formsemestre
def get_evaluations(self) -> list[Evaluation]: def get_evaluations(self) -> list[Evaluation]:
"Liste de toutes les évaluations du semestre, triées par module/numero" "Liste de toutes les évaluations du semestre, triées par module/numero"
return ( return (

View File

@ -3,7 +3,6 @@
""" """
import pandas as pd import pandas as pd
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
import sqlalchemy as sa
from app import db from app import db
from app.auth.models import User from app.auth.models import User

View File

@ -160,6 +160,10 @@ class Module(db.Model):
"Identifiant du module à afficher : abbrev ou titre ou code" "Identifiant du module à afficher : abbrev ou titre ou code"
return self.abbrev or self.titre or self.code return self.abbrev or self.titre or self.code
def sort_key(self) -> tuple:
"""Clé de tri pour formations classiques"""
return self.numero or 0, self.code
def sort_key_apc(self) -> tuple: def sort_key_apc(self) -> tuple:
"""Clé de tri pour avoir """Clé de tri pour avoir
présentation par type (res, sae), parcours, type, numéro présentation par type (res, sae), parcours, type, numéro

View File

@ -27,11 +27,12 @@
"""Accès aux emplois du temps """Accès aux emplois du temps
XXX usage uniquement experimental pour tests implémentations Lecture et conversion des ics.
""" """
from datetime import timezone from datetime import timezone
import re import re
import time
import icalendar import icalendar
from flask import g, url_for from flask import g, url_for
@ -135,6 +136,7 @@ def formsemestre_edt_dict(
toujours présents. toujours présents.
TODO: spécifier intervalle de dates start et end TODO: spécifier intervalle de dates start et end
""" """
t0 = time.time()
group_ids_set = set(group_ids) if group_ids else set() group_ids_set = set(group_ids) if group_ids else set()
try: try:
events_scodoc, _ = load_and_convert_ics(formsemestre) events_scodoc, _ = load_and_convert_ics(formsemestre)
@ -227,7 +229,9 @@ def formsemestre_edt_dict(
"moduleimpl_id": modimpl.id if modimpl else None, "moduleimpl_id": modimpl.id if modimpl else None,
} }
events_cal.append(d) events_cal.append(d)
log(
f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s"
)
return events_cal return events_cal
@ -399,56 +403,6 @@ def extract_event_data(
return data return data
# 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_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( def formsemestre_retreive_modimpls_from_edt_id(
formsemestre: FormSemestre, formsemestre: FormSemestre,
) -> dict[str, ModuleImpl]: ) -> dict[str, ModuleImpl]:

View File

@ -6,6 +6,10 @@
--sco-content-max-width: 1024px; --sco-content-max-width: 1024px;
--sco-color-explication: rgb(10, 58, 140); --sco-color-explication: rgb(10, 58, 140);
--sco-color-background: rgb(242, 242, 238); --sco-color-background: rgb(242, 242, 238);
--sco-color-mod-std: #afafc2;
--sco-color-ressources: #f8c844;
--sco-color-saes: #c6ffab;
--sco-color-ues: #0051a9;
} }
html, html,
@ -2421,15 +2425,15 @@ div.formation_list_modules {
} }
div.formation_list_modules_RESSOURCE { div.formation_list_modules_RESSOURCE {
background-color: #f8c844; background-color: var(--sco-color-ressources);
} }
div.formation_list_modules_SAE { div.formation_list_modules_SAE {
background-color: #c6ffab; background-color: var(--sco-color-saes);
} }
div.formation_list_modules_STANDARD { div.formation_list_modules_STANDARD {
background-color: #afafc2; background-color: var(--sco-color-mod-std);
} }
div.formation_list_modules_titre { div.formation_list_modules_titre {

View File

@ -0,0 +1,96 @@
{# Tableau de bord utilisateur #}
{% extends "sco_page.j2" %}
{% block app_content %}
<style>
.ub-formsemestres {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
}
.ub-formsemestre {
flex-basis: 256px; /* largeur boite par défaut */
display: flex;
flex-direction: column;
justify-content: space-between;
background-color: #fdcf52;
border-radius: 18px;
margin-left: 10px;
margin-right: 10px;
margin-bottom: 10px;
min-width: 128px;
max-width: fit-content;
--color-links: rgb(153, 51, 51);
}
.ub-sem-titre {
font-weight: bold;
font-size: 110%;
padding: 8px 16px;
}
.ub-sem-titre a.stdlink, .ub-sem-titre a.stdlink:visited {
color: black;
text-decoration: none;
}
.ub-sem-titre a.stdlink:hover {
color: var(--color-links);
text-decoration: underline;
}
.ub-sem-modimpls {
background-color: rgb(210, 210, 210);
border-bottom-left-radius: 18px;
border-bottom-right-radius: 18px;
font-weight: normal;
font-style: normal;
margin-top: 0px;
margin-bottom: 0px;
padding: 8px 16px;
}
.ub-formsemestre .code {
display: inline-block;
margin-right: 8px;
min-width: 48px;
}
.ub-formsemestre a.formsemestre_status_link:hover {
color: var(--color-links);
text-decoration: underline;
}
.ub-formsemestre a.formsemestre_status_link, .ub-formsemestre a.formsemestre_status_link:visited{
color: black;
text-decoration: none;
}
</style>
<div class="tab-content">
<h2>Votre tableau de bord, {{user.get_nomcomplet()}}</h2>
<h3>Vos modules</h3>
<div class="ub-formsemestres">
{% for formsemestre in formsemestres %}
<div class="ub-formsemestre">
<div class="ub-sem-titre">{{formsemestre.html_link_status()|safe}}</div>
<div class="ub-sem-modimpls">
{% for modimpl in modimpls_by_formsemestre[formsemestre.id] %}
<div>
<span class="code"><a class="stdlink" href="{{
url_for('notes.moduleimpl_status',
scodoc_dept=formsemestre.departement.acronym, moduleimpl_id=modimpl.id)
}}">{{modimpl.module.code}}</a>
</span>
<span><a class="formsemestre_status_link" href="{{
url_for('notes.moduleimpl_status',
scodoc_dept=formsemestre.departement.acronym, moduleimpl_id=modimpl.id)
}}">{{modimpl.module.titre_str()}}</a>
</span>
</div>
{% else %}
<div class="empty">pas de modules</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock app_content %}

View File

@ -124,4 +124,5 @@ from app.views import (
scodoc, scodoc,
scolar, scolar,
users, users,
user_board,
) )

39
app/views/user_board.py Normal file
View File

@ -0,0 +1,39 @@
"""
Tableau de bord utilisateur
Emmanuel Viennet, 2023
"""
from flask import flash, redirect, render_template, url_for
from flask import g, request
from app.auth.models import User
from app.decorators import (
scodoc,
permission_required,
)
from app.models import FormSemestre
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
from app.views import scolar_bp as bp
from app.views import ScoData
@bp.route("/user_board/<string:user_name>")
@scodoc
@permission_required(Permission.ScoView)
def user_board(user_name: str):
"""Tableau de bord utilisateur: liens vers ses objets"""
user = User.query.filter_by(user_name=user_name).first_or_404()
formsemestres, modimpls_by_formsemestre = FormSemestre.get_user_formsemestres_annee(
user
)
# TODO: le calendrier avec ses enseignements
return render_template(
"user_board/user_board.j2",
formsemestres=formsemestres,
modimpls_by_formsemestre=modimpls_by_formsemestre,
sco=ScoData(),
title=f"{user.get_prenomnom()}: tableau de bord",
user=user,
)