From ae94d8fba4b056a22d7fe100e8a84a81a1db439c Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 29 Dec 2023 13:58:18 +0100 Subject: [PATCH] WIP: tableau de bord utilisateur --- .gitignore | 3 + app/models/formsemestre.py | 77 ++++++++++++++++++++- app/models/moduleimpls.py | 1 - app/models/modules.py | 4 ++ app/scodoc/sco_edt_cal.py | 58 ++-------------- app/static/css/scodoc.css | 10 ++- app/templates/user_board/user_board.j2 | 96 ++++++++++++++++++++++++++ app/views/__init__.py | 1 + app/views/user_board.py | 39 +++++++++++ 9 files changed, 230 insertions(+), 59 deletions(-) create mode 100644 app/templates/user_board/user_board.j2 create mode 100644 app/views/user_board.py diff --git a/.gitignore b/.gitignore index b60367ec8..22d1be8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ copy # Symlinks static ScoDoc app/static/links/[0-9]*.*[0-9] + +# Essais locaux +xp/ diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 83d09fdd6..dc6b8cf19 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -10,8 +10,10 @@ """ScoDoc models: formsemestre """ +from collections import defaultdict import datetime from functools import cached_property +from itertools import chain from operator import attrgetter 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.formations import Formation 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.ues import UniteEns 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_vdi import ApoEtapeVDI -from app.scodoc.sco_utils import translate_assiduites_metric - GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes @@ -380,6 +384,73 @@ class FormSemestre(db.Model): _cache[key] = 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]: "Liste de toutes les évaluations du semestre, triées par module/numero" return ( diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 00966033e..a98fcf0f2 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -3,7 +3,6 @@ """ import pandas as pd from flask_sqlalchemy.query import Query -import sqlalchemy as sa from app import db from app.auth.models import User diff --git a/app/models/modules.py b/app/models/modules.py index 31c561925..0410395ca 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -160,6 +160,10 @@ class Module(db.Model): "Identifiant du module à afficher : abbrev ou titre ou 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: """Clé de tri pour avoir présentation par type (res, sae), parcours, type, numéro diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index 6781f7fe7..bc7dc8d3a 100644 --- a/app/scodoc/sco_edt_cal.py +++ b/app/scodoc/sco_edt_cal.py @@ -27,11 +27,12 @@ """Accès aux emplois du temps -XXX usage uniquement experimental pour tests implémentations +Lecture et conversion des ics. """ from datetime import timezone import re +import time import icalendar from flask import g, url_for @@ -135,6 +136,7 @@ def formsemestre_edt_dict( toujours présents. TODO: spécifier intervalle de dates start et end """ + t0 = time.time() group_ids_set = set(group_ids) if group_ids else set() try: events_scodoc, _ = load_and_convert_ics(formsemestre) @@ -227,7 +229,9 @@ def formsemestre_edt_dict( "moduleimpl_id": modimpl.id if modimpl else None, } events_cal.append(d) - + log( + f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s" + ) return events_cal @@ -399,56 +403,6 @@ def extract_event_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( formsemestre: FormSemestre, ) -> dict[str, ModuleImpl]: diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index ed027f532..ee7a91c6e 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -6,6 +6,10 @@ --sco-content-max-width: 1024px; --sco-color-explication: rgb(10, 58, 140); --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, @@ -2421,15 +2425,15 @@ div.formation_list_modules { } div.formation_list_modules_RESSOURCE { - background-color: #f8c844; + background-color: var(--sco-color-ressources); } div.formation_list_modules_SAE { - background-color: #c6ffab; + background-color: var(--sco-color-saes); } div.formation_list_modules_STANDARD { - background-color: #afafc2; + background-color: var(--sco-color-mod-std); } div.formation_list_modules_titre { diff --git a/app/templates/user_board/user_board.j2 b/app/templates/user_board/user_board.j2 new file mode 100644 index 000000000..1413a15ae --- /dev/null +++ b/app/templates/user_board/user_board.j2 @@ -0,0 +1,96 @@ +{# Tableau de bord utilisateur #} + +{% extends "sco_page.j2" %} + +{% block app_content %} + + +
+

Votre tableau de bord, {{user.get_nomcomplet()}}

+ +

Vos modules

+
+ {% for formsemestre in formsemestres %} +
+
{{formsemestre.html_link_status()|safe}}
+
+ {% for modimpl in modimpls_by_formsemestre[formsemestre.id] %} + + {% else %} +
pas de modules
+ {% endfor %} +
+
+ {% endfor %} +
+
+ +{% endblock app_content %} diff --git a/app/views/__init__.py b/app/views/__init__.py index fdad9b5fb..5eed78022 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -124,4 +124,5 @@ from app.views import ( scodoc, scolar, users, + user_board, ) diff --git a/app/views/user_board.py b/app/views/user_board.py new file mode 100644 index 000000000..8f2e75ea3 --- /dev/null +++ b/app/views/user_board.py @@ -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/") +@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, + )