diff --git a/app/api/__init__.py b/app/api/__init__.py index a6f2b680..fb994bfd 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -3,9 +3,11 @@ from flask_json import as_json from flask import Blueprint from flask import request, g +from flask_login import current_user from app import db from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import AccessDenied, ScoException +from app.scodoc.sco_permissions import Permission api_bp = Blueprint("api", __name__) api_web_bp = Blueprint("apiweb", __name__) @@ -48,13 +50,21 @@ def requested_format(default_format="json", allowed_formats=None): @as_json -def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None): +def get_model_api_object( + model_cls: db.Model, + model_id: int, + join_cls: db.Model = None, + restrict: bool | None = None, +): """ Retourne une réponse contenant la représentation api de l'objet "Model[model_id]" Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> join_cls exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py + + L'agument restrict est passé to_dict, est signale que l'on veut une version restreinte + (sans données personnelles, ou sans informations sur le justificatif d'absence) """ query = model_cls.query.filter_by(id=model_id) if g.scodoc_dept and join_cls is not None: @@ -66,8 +76,9 @@ def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model 404, message=f"{model_cls.__name__} inexistant(e)", ) - - return unique.to_dict(format_api=True) + if restrict is None: + return unique.to_dict(format_api=True) + return unique.to_dict(format_api=True, restrict=restrict) from app.api import tokens diff --git a/app/api/etudiants.py b/app/api/etudiants.py index a508ee3b..d66c648d 100755 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -104,7 +104,8 @@ def etudiants_courants(long=False): or_(Departement.acronym == acronym for acronym in allowed_depts) ) if long: - data = [etud.to_dict_api() for etud in etuds] + restrict = not current_user.has_permission(Permission.ViewEtudData) + data = [etud.to_dict_api(restrict=restrict) for etud in etuds] else: data = [etud.to_dict_short() for etud in etuds] return data @@ -138,8 +139,8 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None): 404, message="étudiant inconnu", ) - - return etud.to_dict_api() + restrict = not current_user.has_permission(Permission.ViewEtudData) + return etud.to_dict_api(restrict=restrict) @bp.route("/etudiant/etudid//photo") @@ -251,7 +252,8 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None): query = query.join(Departement).filter( or_(Departement.acronym == acronym for acronym in allowed_depts) ) - return [etud.to_dict_api() for etud in query] + restrict = not current_user.has_permission(Permission.ViewEtudData) + return [etud.to_dict_api(restrict=restrict) for etud in query] @bp.route("/etudiants/name/") @@ -278,7 +280,11 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32): ) etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit) # Note: on raffine le tri pour les caractères spéciaux et nom usuel ici: - return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))] + restrict = not current_user.has_permission(Permission.ViewEtudData) + return [ + etud.to_dict_api(restrict=restrict) + for etud in sorted(etuds, key=attrgetter("sort_key")) + ] @bp.route("/etudiant/etudid//formsemestres") @@ -543,7 +549,8 @@ def etudiant_create(force=False): # Note: je ne comprends pas pourquoi un refresh est nécessaire ici # sans ce refresh, etud.__dict__ est incomplet (pas de 'nom'). db.session.refresh(etud) - r = etud.to_dict_api() + + r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer return r @@ -590,5 +597,6 @@ def etudiant_edit( # Note: je ne comprends pas pourquoi un refresh est nécessaire ici # sans ce refresh, etud.__dict__ est incomplet (pas de 'nom'). db.session.refresh(etud) - r = etud.to_dict_api() + restrict = not current_user.has_permission(Permission.ViewEtudData) + r = etud.to_dict_api(restrict=restrict) return r diff --git a/app/api/evaluations.py b/app/api/evaluations.py index 76b9be7c..510ad988 100644 --- a/app/api/evaluations.py +++ b/app/api/evaluations.py @@ -67,7 +67,7 @@ def get_evaluation(evaluation_id: int): @scodoc @permission_required(Permission.ScoView) @as_json -def evaluations(moduleimpl_id: int): +def moduleimpl_evaluations(moduleimpl_id: int): """ Retourne la liste des évaluations d'un moduleimpl @@ -75,14 +75,8 @@ def evaluations(moduleimpl_id: int): Exemple de résultat : voir /evaluation """ - query = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id) - if g.scodoc_dept: - query = ( - query.join(ModuleImpl) - .join(FormSemestre) - .filter_by(dept_id=g.scodoc_dept_id) - ) - return [e.to_dict_api() for e in query] + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) + return [evaluation.to_dict_api() for evaluation in modimpl.evaluations] @bp.route("/evaluation//notes") diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 6fc38aea..662ddd4d 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -11,7 +11,7 @@ from operator import attrgetter, itemgetter from flask import g, make_response, request from flask_json import as_json -from flask_login import login_required +from flask_login import current_user, login_required import app from app import db @@ -360,7 +360,8 @@ def formsemestre_etudiants( inscriptions = formsemestre.inscriptions if long: - etuds = [ins.etud.to_dict_api() for ins in inscriptions] + restrict = not current_user.has_permission(Permission.ViewEtudData) + etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions] else: etuds = [ins.etud.to_dict_short() for ins in inscriptions] # Ajout des groupes de chaque étudiants diff --git a/app/api/jury.py b/app/api/jury.py index e71531eb..864dfe22 100644 --- a/app/api/jury.py +++ b/app/api/jury.py @@ -66,7 +66,7 @@ def _news_delete_jury_etud(etud: Identite): "génère news sur effacement décision" # n'utilise pas g.scodoc_dept, pas toujours dispo en mode API url = url_for( - "scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=etud.departement.acronym, etudid=etud.id ) ScolarNews.add( typ=ScolarNews.NEWS_JURY, diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index a22ca39a..a24b0f27 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -15,7 +15,7 @@ from werkzeug.exceptions import NotFound import app.scodoc.sco_assiduites as scass import app.scodoc.sco_utils as scu -from app import db +from app import db, set_sco_dept from app.api import api_bp as bp from app.api import api_web_bp from app.api import get_model_api_object, tools @@ -53,14 +53,19 @@ def justificatif(justif_id: int = None): "date_fin": "2022-10-31T10:00+01:00", "etat": "valide", "fichier": "archive_id", - "raison": "une raison", + "raison": "une raison", // VIDE si pas le droit "entry_date": "2022-10-31T08:00+01:00", "user_id": 1 or null, } """ - return get_model_api_object(Justificatif, justif_id, Identite) + return get_model_api_object( + Justificatif, + justif_id, + Identite, + restrict=not current_user.has_permission(Permission.AbsJustifView), + ) # etudid @@ -133,8 +138,9 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal # Mise en forme des données puis retour en JSON data_set: list[dict] = [] + restrict = not current_user.has_permission(Permission.AbsJustifView) for just in justificatifs_query.all(): - data = just.to_dict(format_api=True) + data = just.to_dict(format_api=True, restrict=restrict) data_set.append(data) return data_set @@ -151,7 +157,10 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal @as_json @permission_required(Permission.ScoView) def justificatifs_dept(dept_id: int = None, with_query: bool = False): - """XXX TODO missing doc""" + """ + Renvoie tous les justificatifs d'un département + (en ajoutant un champ "formsemestre" si possible) + """ # Récupération du département et des étudiants du département dept: Departement = Departement.query.get(dept_id) @@ -169,14 +178,15 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False): justificatifs_query: Query = _filter_manager(request, justificatifs_query) # Mise en forme des données et retour JSON + restrict = not current_user.has_permission(Permission.AbsJustifView) data_set: list[dict] = [] for just in justificatifs_query: - data_set.append(_set_sems(just)) + data_set.append(_set_sems(just, restrict=restrict)) return data_set -def _set_sems(justi: Justificatif) -> dict: +def _set_sems(justi: Justificatif, restrict: bool) -> dict: """ _set_sems Ajoute le formsemestre associé au justificatif s'il existe @@ -189,7 +199,7 @@ def _set_sems(justi: Justificatif) -> dict: dict: La représentation de l'assiduité en dictionnaire """ # Conversion du justificatif en dictionnaire - data = justi.to_dict(format_api=True) + data = justi.to_dict(format_api=True, restrict=restrict) # Récupération du formsemestre de l'assiduité formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict()) @@ -243,9 +253,10 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False): justificatifs_query: Query = _filter_manager(request, justificatifs_query) # Retour des justificatifs en JSON + restrict = not current_user.has_permission(Permission.AbsJustifView) data_set: list[dict] = [] for justi in justificatifs_query.all(): - data = justi.to_dict(format_api=True) + data = justi.to_dict(format_api=True, restrict=restrict) data_set.append(data) return data_set @@ -294,6 +305,7 @@ def justif_create(etudid: int = None, nip=None, ine=None): 404, message="étudiant inconnu", ) + set_sco_dept(etud.departement.acronym) # Récupération des justificatifs à créer create_list: list[object] = request.get_json(force=True) diff --git a/app/api/moduleimpl.py b/app/api/moduleimpl.py index a4843fdf..4b5db1b0 100644 --- a/app/api/moduleimpl.py +++ b/app/api/moduleimpl.py @@ -8,16 +8,14 @@ ScoDoc 9 API : accès aux moduleimpl """ -from flask import g from flask_json import as_json from flask_login import login_required +import app from app.api import api_bp as bp, api_web_bp from app.decorators import scodoc, permission_required -from app.models import ( - FormSemestre, - ModuleImpl, -) +from app.models import ModuleImpl +from app.scodoc import sco_liste_notes from app.scodoc.sco_permissions import Permission @@ -62,10 +60,7 @@ def moduleimpl(moduleimpl_id: int): } } """ - query = ModuleImpl.query.filter_by(id=moduleimpl_id) - if g.scodoc_dept: - query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) - modimpl: ModuleImpl = query.first_or_404() + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) return modimpl.to_dict(convert_objects=True) @@ -87,8 +82,36 @@ def moduleimpl_inscriptions(moduleimpl_id: int): ... ] """ - query = ModuleImpl.query.filter_by(id=moduleimpl_id) - if g.scodoc_dept: - query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) - modimpl: ModuleImpl = query.first_or_404() + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) return [i.to_dict() for i in modimpl.inscriptions] + + +@bp.route("/moduleimpl//notes") +@api_web_bp.route("/moduleimpl//notes") +@login_required +@scodoc +@permission_required(Permission.ScoView) +def moduleimpl_notes(moduleimpl_id: int): + """Liste des notes dans ce moduleimpl + Exemple de résultat : + [ + { + "etudid": 17776, // code de l'étudiant + "nom": "DUPONT", + "prenom": "Luz", + "38411": 16.0, // Note dans l'évaluation d'id 38411 + "38410": 15.0, + "moymod": 15.5, // Moyenne INDICATIVE module + "moy_ue_2875": 15.5, // Moyenne vers l'UE 2875 + "moy_ue_2876": 15.5, // Moyenne vers l'UE 2876 + "moy_ue_2877": 15.5 // Moyenne vers l'UE 2877 + }, + ... + ] + """ + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) + app.set_sco_dept(modimpl.formsemestre.departement.acronym) + table, _ = sco_liste_notes.do_evaluation_listenotes( + moduleimpl_id=modimpl.id, fmt="json" + ) + return table diff --git a/app/api/users.py b/app/api/users.py index 0a9f7397..c038ffb8 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -7,7 +7,6 @@ """ ScoDoc 9 API : accès aux utilisateurs """ -import datetime from flask import g, request from flask_json import as_json @@ -15,13 +14,14 @@ from flask_login import current_user, login_required from app import db, log from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR -from app.scodoc.sco_utils import json_error from app.auth.models import User, Role, UserRole from app.auth.models import is_valid_password from app.decorators import scodoc, permission_required -from app.models import Departement +from app.models import Departement, ScoDocSiteConfig +from app.scodoc import sco_edt_cal from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import json_error from app.scodoc import sco_utils as scu @@ -441,3 +441,63 @@ def role_delete(role_name: str): db.session.delete(role) db.session.commit() return {"OK": True} + + +# @bp.route("/user//edt") +# @api_web_bp.route("/user//edt") +# @login_required +# @scodoc +# @permission_required(Permission.ScoView) +# @as_json +# def user_edt(uid: int): +# """L'emploi du temps de l'utilisateur. +# Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur. + +# show_modules_titles affiche le titre complet du module (défaut), sinon juste le code. + +# Il faut la permission ScoView + (UsersView ou bien être connecté comme l'utilisateur demandé) +# """ +# if g.scodoc_dept is None: # route API non départementale +# if not current_user.has_permission(Permission.UsersView): +# return scu.json_error(403, "accès non autorisé") +# user: User = db.session.get(User, uid) +# if user is None: +# return json_error(404, "user not found") +# # Check permission +# if current_user.id != user.id: +# if g.scodoc_dept: +# allowed_depts = current_user.get_depts_with_permission(Permission.UsersView) +# if (None not in allowed_depts) and (user.dept not in allowed_depts): +# return json_error(404, "user not found") + +# show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False)) + +# # Cherche ics +# if not user.edt_id: +# return json_error(404, "user not configured") +# ics_filename = sco_edt_cal.get_ics_user_edt_filename(user.edt_id) +# if not ics_filename: +# return json_error(404, "no calendar for this user") + +# _, calendar = sco_edt_cal.load_calendar(ics_filename) + +# # TODO: +# # - Construire mapping edt2modimpl: edt_id -> modimpl +# # pour cela, considérer tous les formsemestres de la période de l'edt +# # (soit on considère l'année scolaire du 1er event, ou celle courante, +# # soit on cherche min, max des dates des events) +# # - Modifier décodage des groupes dans convert_ics pour avoi run mapping +# # de groupe par semestre (retrouvé grâce au modimpl associé à l'event) + +# raise NotImplementedError() # TODO XXX WIP + +# events_scodoc, _ = sco_edt_cal.convert_ics( +# calendar, +# edt2group=edt2group, +# default_group=default_group, +# edt2modimpl=edt2modimpl, +# ) +# edt_dict = sco_edt_cal.translate_calendar( +# events_scodoc, group_ids, show_modules_titles=show_modules_titles +# ) +# return edt_dict diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index c8cf061d..5ec30c5e 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -499,10 +499,8 @@ class BulletinBUT: d["etud"]["etat_civil"] = etud.etat_civil d.update(self.res.sem) etud_etat = self.res.get_etud_etat(etud.id) - d["filigranne"] = sco_bulletins_pdf.get_filigranne( - etud_etat, - self.prefs, - decision_sem=d["semestre"].get("decision"), + d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc( + etud_etat, self.prefs, etud.id, res=self.res ) if etud_etat == scu.DEMISSION: d["demission"] = "(Démission)" diff --git a/app/but/bulletin_but_court.py b/app/but/bulletin_but_court.py index ea52d0f2..c8d25828 100644 --- a/app/but/bulletin_but_court.py +++ b/app/but/bulletin_but_court.py @@ -35,6 +35,7 @@ from app.decorators import ( permission_required, ) from app.models import FormSemestre, FormSemestreInscription, Identite +from app.scodoc import sco_bulletins_pdf from app.scodoc.codes_cursus import UE_STANDARD from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError from app.scodoc.sco_logos import find_logo @@ -104,8 +105,10 @@ def _build_bulletin_but_infos( bulletins_sem = BulletinBUT(formsemestre) if fmt == "pdf": bul: dict = bulletins_sem.bulletin_etud_complet(etud) + filigranne = bul["filigranne"] else: # la même chose avec un peu moins d'infos bul: dict = bulletins_sem.bulletin_etud(etud, force_publishing=True) + filigranne = "" decision_ues = ( {x["acronyme"]: x for x in bul["semestre"]["decision_ue"]} if "semestre" in bul and "decision_ue" in bul["semestre"] @@ -131,6 +134,7 @@ def _build_bulletin_but_infos( "decision_ues": decision_ues, "ects_total": ects_total, "etud": etud, + "filigranne": filigranne, "formsemestre": formsemestre, "logo": logo, "prefs": bulletins_sem.prefs, diff --git a/app/but/bulletin_but_court_pdf.py b/app/but/bulletin_but_court_pdf.py index bef62ae4..58528045 100644 --- a/app/but/bulletin_but_court_pdf.py +++ b/app/but/bulletin_but_court_pdf.py @@ -48,6 +48,7 @@ def make_bulletin_but_court_pdf( ects_total: float = 0.0, etud: Identite = None, formsemestre: FormSemestre = None, + filigranne="" logo: Logo = None, prefs: SemPreferences = None, title: str = "", @@ -86,6 +87,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard): decision_ues: dict = None, ects_total: float = 0.0, etud: Identite = None, + filigranne="", formsemestre: FormSemestre = None, logo: Logo = None, prefs: SemPreferences = None, @@ -95,7 +97,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard): ] = None, ues_acronyms: list[str] = None, ): - super().__init__(bul, authuser=current_user) + super().__init__(bul, authuser=current_user, filigranne=filigranne) self.bul = bul self.cursus = cursus self.decision_ues = decision_ues diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 41c08119..0a7779f2 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -380,14 +380,24 @@ class DecisionsProposeesAnnee(DecisionsProposees): sco_codes.ADJ, ] + self.codes explanation += f" et {self.nb_rcues_under_8} < 8" - else: - self.codes = [ - sco_codes.RED, - sco_codes.NAR, - sco_codes.PAS1NCI, - sco_codes.ADJ, - sco_codes.PASD, # voir #488 (discutable, conventions locales) - ] + self.codes + else: # autres cas: non admis, non passage, non dem, pas la moitié des rcue: + if formsemestre.semestre_id % 2 and self.formsemestre_pair is None: + # Si jury sur un seul semestre impair, ne propose pas redoublement + # et efface décision éventuellement existante + codes = [None] + else: + codes = [] + self.codes = ( + codes + + [ + sco_codes.RED, + sco_codes.NAR, + sco_codes.PAS1NCI, + sco_codes.ADJ, + sco_codes.PASD, # voir #488 (discutable, conventions locales) + ] + + self.codes + ) explanation += f""" et {self.nb_rcues_under_8 } niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8""" @@ -514,7 +524,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): """Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF) du niveau auquel appartient formsemestre. - -> S_impair, S_pair + -> S_impair, S_pair (de la même année scolaire) Si l'origine est impair, S_impair est l'origine et S_pair est None Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur @@ -524,9 +534,9 @@ class DecisionsProposeesAnnee(DecisionsProposees): return None, None if formsemestre.semestre_id % 2: - idx_autre = formsemestre.semestre_id + 1 + idx_autre = formsemestre.semestre_id + 1 # impair, autre = suivant else: - idx_autre = formsemestre.semestre_id - 1 + idx_autre = formsemestre.semestre_id - 1 # pair: autre = précédent # Cherche l'autre semestre de la même année scolaire: autre_formsemestre = None @@ -610,6 +620,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): def next_semestre_ids(self, code: str) -> set[int]: """Les indices des semestres dans lequels l'étudiant est autorisé à poursuivre après le semestre courant. + code: code jury sur année BUT """ # La poursuite d'études dans un semestre pair d'une même année # est de droit pour tout étudiant. @@ -653,6 +664,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): Si les code_rcue et le code_annee ne sont pas fournis, et qu'il n'y en a pas déjà, enregistre ceux par défaut. + + Si le code_annee est None, efface le code déjà enregistré. """ log("jury_but.DecisionsProposeesAnnee.record_form") code_annee = self.codes[0] # si pas dans le form, valeur par defaut @@ -697,6 +710,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): def record(self, code: str, mark_recorded: bool = True) -> bool: """Enregistre le code de l'année, et au besoin l'autorisation d'inscription. Si l'étudiant est DEM ou DEF, ne fait rien. + Si le code est None, efface le code déjà enregistré. Si mark_recorded est vrai, positionne self.recorded """ if self.inscription_etat != scu.INSCRIT: @@ -746,7 +760,9 @@ class DecisionsProposeesAnnee(DecisionsProposees): return True def record_autorisation_inscription(self, code: str): - """Autorisation d'inscription dans semestre suivant""" + """Autorisation d'inscription dans semestre suivant. + code: code jury sur année BUT + """ if self.autorisations_recorded: return if self.inscription_etat != scu.INSCRIT: diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py index 9b970a61..8ae09800 100644 --- a/app/but/jury_but_pv.py +++ b/app/but/jury_but_pv.py @@ -154,7 +154,7 @@ def pvjury_table_but( "_nom_target_attrs": f'class="etudinfo" id="{etud.id}"', "_nom_td_attrs": f'id="{etud.id}" class="etudinfo"', "_nom_target": url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id, ), diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 544d13f2..8536ac63 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -447,7 +447,7 @@ def jury_but_semestriel(
{etud.nomprenom}
diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 89ff95e7..673668b9 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -234,7 +234,7 @@ class ResultatsSemestreClassic(NotesTableCompat): raise ScoValueError( f"""

Coefficient de l'UE capitalisée {ue.acronyme} impossible à déterminer pour l'étudiant {etud.nom_disp()}

Il faut 59: @@ -118,14 +77,36 @@ def check_ics_regexp(form, field): class ConfigAssiduitesForm(FlaskForm): "Formulaire paramétrage Module Assiduité" - - assi_morning_time = TimeField( - "Début de la journée" - ) # TODO utiliser TextField + timepicker voir AjoutAssiOrJustForm - assi_lunch_time = TimeField( - "Heure de midi (date pivot entre matin et après-midi)" - ) # TODO - assi_afternoon_time = TimeField("Fin de la journée") # TODO + assi_morning_time = StringField( + "Début de la journée", + default="", + validators=[Length(max=5)], + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_morning_time", + }, + ) + assi_lunch_time = StringField( + "Heure de midi (date pivot entre matin et après-midi)", + default="", + validators=[Length(max=5)], + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_lunch_time", + }, + ) + assi_afternoon_time = StringField( + "Fin de la journée", + validators=[Length(max=5)], + default="", + render_kw={ + "class": "timepicker", + "size": 5, + "id": "assi_afternoon_time", + }, + ) assi_tick_time = DecimalField( "Granularité de la timeline (temps en minutes)", diff --git a/app/forms/main/config_rgpd.py b/app/forms/main/config_rgpd.py new file mode 100644 index 00000000..d20ff717 --- /dev/null +++ b/app/forms/main/config_rgpd.py @@ -0,0 +1,49 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Formulaire configuration RGPD +""" + +from flask_wtf import FlaskForm +from wtforms import SubmitField +from wtforms.fields.simple import TextAreaField + + +class ConfigRGPDForm(FlaskForm): + "Formulaire paramétrage RGPD" + rgpd_coordonnees_dpo = TextAreaField( + label="Optionnel: coordonnées du DPO", + description="""Le délégué à la protection des données (DPO) est chargé de mettre en œuvre + la conformité au règlement européen sur la protection des données (RGPD) au sein de l’organisme. + Indiquer ici les coordonnées (format libre) qui seront affichées aux utilisateurs de ScoDoc. + """, + render_kw={"rows": 5, "cols": 72}, + ) + + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 3d6a296c..89ab7fe6 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -2,6 +2,7 @@ """Gestion de l'assiduité (assiduités + justificatifs) """ from datetime import datetime + from flask_login import current_user from flask_sqlalchemy.query import Query from sqlalchemy.exc import DataError @@ -88,8 +89,10 @@ class Assiduite(ScoDocModel): lazy="select", ) - def to_dict(self, format_api=True) -> dict: - """Retourne la représentation json de l'assiduité""" + def to_dict(self, format_api=True, restrict: bool | None = None) -> dict: + """Retourne la représentation json de l'assiduité + restrict n'est pas utilisé ici. + """ etat = self.etat user: User | None = None if format_api: @@ -252,43 +255,19 @@ class Assiduite(ScoDocModel): def set_moduleimpl(self, moduleimpl_id: int | str): """Mise à jour du moduleimpl_id - Les valeurs du champs "moduleimpl_id" possibles sont : + Les valeurs du champ "moduleimpl_id" possibles sont : - (un id classique) - ("autre" ou "") - - None (pas de moduleimpl_id) + - "" (pas de moduleimpl_id) Si la valeur est "autre" il faut: - mettre à None assiduité.moduleimpl_id - mettre à jour assiduite.external_data["module"] = "autre" - En fonction de la configuration du semestre la valeur `None` peut-être considérée comme invalide. + En fonction de la configuration du semestre (option force_module) la valeur "" peut-être + considérée comme invalide. - Il faudra donc vérifier que ce n'est pas le cas avant de mettre à jour l'assiduité """ moduleimpl: ModuleImpl = None - try: - # ne lève une erreur que si moduleimpl_id est une chaine de caractère non parsable (parseInt) - moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) - # moduleImpl est soit : - # - None si moduleimpl_id==None - # - None si moduleimpl_id== non reconnu - # - ModuleImpl si valide - - # Vérification ModuleImpl not None (raise ScoValueError) - if moduleimpl is None and self._check_force_module(moduleimpl): - # Ici uniquement si on est autorisé à ne pas avoir de module - self.moduleimpl_id = None - return - - # Vérification Inscription ModuleImpl (raise ScoValueError) - if moduleimpl.est_inscrit(self.etudiant): - self.moduleimpl_id = moduleimpl.id - else: - raise ScoValueError("L'étudiant n'est pas inscrit au module") - - except DataError: - # On arrive ici si moduleimpl_id == "autre" ou moduleimpl_id == non parsé - - if moduleimpl_id != "autre": - raise ScoValueError("Module non reconnu") - + if moduleimpl_id == "autre": # Configuration de external_data pour Module Autre # Si self.external_data None alors on créé un dictionnaire {"module": "autre"} # Sinon on met à jour external_data["module"] à "autre" @@ -302,6 +281,29 @@ class Assiduite(ScoDocModel): self.moduleimpl_id = None # Ici pas de vérification du force module car on l'a mis dans "external_data" + return + + if moduleimpl_id != "": + try: + moduleimpl_id = int(moduleimpl_id) + except ValueError as exc: + raise ScoValueError("Module non reconnu") from exc + moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) + + # ici moduleimpl est None si non spécifié + + # Vérification ModuleImpl not None (raise ScoValueError) + if moduleimpl is None: + self._check_force_module() + # Ici uniquement si on est autorisé à ne pas avoir de module + self.moduleimpl_id = None + return + + # Vérification Inscription ModuleImpl (raise ScoValueError) + if moduleimpl.est_inscrit(self.etudiant): + self.moduleimpl_id = moduleimpl.id + else: + raise ScoValueError("L'étudiant n'est pas inscrit au module") def supprime(self): "Supprime l'assiduité. Log et commit." @@ -331,7 +333,7 @@ class Assiduite(ScoDocModel): return get_formsemestre_from_data(self.to_dict()) def get_module(self, traduire: bool = False) -> int | str: - "TODO" + "TODO documenter" if self.moduleimpl_id is not None: if traduire: modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id) @@ -360,8 +362,12 @@ class Assiduite(ScoDocModel): return f"saisie le {date} {utilisateur}" - def _check_force_module(self, moduleimpl: ModuleImpl) -> bool: - # Vérification si module forcé + def _check_force_module(self): + """Vérification si module forcé: + Si le module est requis, raise ScoValueError + sinon ne fait rien. + """ + # cherche le formsemestre affecté pour utiliser ses préférences formsemestre: FormSemestre = get_formsemestre_from_data( { "etudid": self.etudid, @@ -369,18 +375,15 @@ class Assiduite(ScoDocModel): "date_fin": self.date_fin, } ) - force: bool - - if formsemestre: - force = is_assiduites_module_forced(formsemestre_id=formsemestre.id) - else: - force = is_assiduites_module_forced(dept_id=self.etudiant.dept_id) - + formsemestre_id = formsemestre.id if formsemestre else None + # si pas de formsemestre, utilisera les prefs globales du département + dept_id = self.etudiant.dept_id + force = is_assiduites_module_forced( + formsemestre_id=formsemestre_id, dept_id=dept_id + ) if force: raise ScoValueError("Module non renseigné") - return True - class Justificatif(ScoDocModel): """ @@ -434,6 +437,14 @@ class Justificatif(ScoDocModel): etudiant = db.relationship( "Identite", back_populates="justificatifs", lazy="joined" ) + # En revanche, user est rarement accédé: + user = db.relationship( + "User", + backref=db.backref( + "justificatifs", lazy="select", order_by="Justificatif.entry_date" + ), + lazy="select", + ) external_data = db.Column(db.JSON, nullable=True) @@ -445,20 +456,16 @@ class Justificatif(ScoDocModel): query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) return query.first_or_404() - def to_dict(self, format_api: bool = False) -> dict: - """transformation de l'objet en dictionnaire sérialisable""" + def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict: + """L'objet en dictionnaire sérialisable. + Si restrict, ne donne par la raison et les fichiers et external_data + """ etat = self.etat - username = self.user_id + user: User = self.user if self.user_id is not None else None if format_api: etat = EtatJustificatif.inverse().get(self.etat).name - if self.user_id is not None: - user: User = db.session.get(User, self.user_id) - if user is None: - username = "Non renseigné" - else: - username = user.get_prenomnom() data = { "justif_id": self.justif_id, @@ -467,11 +474,13 @@ class Justificatif(ScoDocModel): "date_debut": self.date_debut, "date_fin": self.date_fin, "etat": etat, - "raison": self.raison, - "fichier": self.fichier, + "raison": None if restrict else self.raison, + "fichier": None if restrict else self.fichier, "entry_date": self.entry_date, - "user_id": username, - "external_data": self.external_data, + "user_id": None if user is None else user.id, # l'uid + "user_name": None if user is None else user.user_name, # le login + "user_nom_complet": None if user is None else user.get_nomcomplet(), + "external_data": None if restrict else self.external_data, } return data @@ -618,6 +627,12 @@ def compute_assiduites_justified( Returns: list[int]: la liste des assiduités qui ont été justifiées. """ + # TODO à optimiser (car très long avec 40000 assiduités) + # On devrait : + # - récupérer uniquement les assiduités qui sont sur la période des justificatifs donnés + # - Pour chaque assiduité trouvée, il faut récupérer les justificatifs qui la justifie + # - Si au moins un justificatif valide couvre la période de l'assiduité alors on la justifie + # Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant if justificatifs is None: justificatifs: list[Justificatif] = Justificatif.query.filter_by( diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 2f9caa06..a03058e9 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -119,6 +119,9 @@ class Identite(models.ScoDocModel): "Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete" ) + # Champs "protégés" par ViewEtudData (RGPD) + protected_attrs = {"boursier"} + def __repr__(self): return ( f"" @@ -176,7 +179,7 @@ class Identite(models.ScoDocModel): def url_fiche(self) -> str: "url de la fiche étudiant" return url_for( - "scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id + "scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id ) @classmethod @@ -418,7 +421,7 @@ class Identite(models.ScoDocModel): return args_dict def to_dict_short(self) -> dict: - """Les champs essentiels""" + """Les champs essentiels (aucune donnée perso protégée)""" return { "id": self.id, "civilite": self.civilite, @@ -433,9 +436,10 @@ class Identite(models.ScoDocModel): "prenom_etat_civil": self.prenom_etat_civil, } - def to_dict_scodoc7(self) -> dict: + def to_dict_scodoc7(self, restrict=False) -> dict: """Représentation dictionnaire, - compatible ScoDoc7 mais sans infos admission + compatible ScoDoc7 mais sans infos admission. + Si restrict, cache les infos "personnelles" si pas permission ViewEtudData """ e_dict = self.__dict__.copy() # dict(self.__dict__) e_dict.pop("_sa_instance_state", None) @@ -446,7 +450,7 @@ class Identite(models.ScoDocModel): e_dict["nomprenom"] = self.nomprenom adresse = self.adresses.first() if adresse: - e_dict.update(adresse.to_dict()) + e_dict.update(adresse.to_dict(restrict=restrict)) return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty def to_dict_bul(self, include_urls=True): @@ -481,7 +485,7 @@ class Identite(models.ScoDocModel): if include_urls and has_request_context(): # test request context so we can use this func in tests under the flask shell d["fiche_url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id ) d["photo_url"] = sco_photos.get_etud_photo_url(self.id) adresse = self.adresses.first() @@ -490,16 +494,22 @@ class Identite(models.ScoDocModel): d["id"] = self.id # a été écrasé par l'id de adresse return d - def to_dict_api(self) -> dict: - """Représentation dictionnaire pour export API, avec adresses et admission.""" + def to_dict_api(self, restrict=False) -> dict: + """Représentation dictionnaire pour export API, avec adresses et admission. + Si restrict, supprime les infos "personnelles" (boursier) + """ e = dict(self.__dict__) e.pop("_sa_instance_state", None) admission = self.admission e["admission"] = admission.to_dict() if admission is not None else None - e["adresses"] = [adr.to_dict() for adr in self.adresses] + e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses] e["dept_acronym"] = self.departement.acronym e.pop("departement", None) e["sort_key"] = self.sort_key + if restrict: + # Met à None les attributs protégés: + for attr in self.protected_attrs: + e[attr] = None return e def inscriptions(self) -> list["FormSemestreInscription"]: @@ -825,12 +835,25 @@ class Adresse(models.ScoDocModel): ) description = db.Column(db.Text) - def to_dict(self, convert_nulls_to_str=False): - """Représentation dictionnaire,""" + # Champs "protégés" par ViewEtudData (RGPD) + protected_attrs = { + "emailperso", + "domicile", + "codepostaldomicile", + "villedomicile", + "telephone", + "telephonemobile", + "fax", + } + + def to_dict(self, convert_nulls_to_str=False, restrict=False): + """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) if convert_nulls_to_str: - return {k: e[k] or "" for k in e} + e = {k: v or "" for k, v in e.items()} + if restrict: + e = {k: v for (k, v) in e.items() if k not in self.protected_attrs} return e @@ -885,12 +908,16 @@ class Admission(models.ScoDocModel): # classement (1..Ngr) par le jury dans le groupe APB apb_classement_gr = db.Column(db.Integer) + # Tous les champs sont "protégés" par ViewEtudData (RGPD) + # sauf: + not_protected_attrs = {"bac", "specialite", "anne_bac"} + def get_bac(self) -> Baccalaureat: "Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères." return Baccalaureat(self.bac, specialite=self.specialite) - def to_dict(self, no_nulls=False): - """Représentation dictionnaire,""" + def to_dict(self, no_nulls=False, restrict=False): + """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" d = dict(self.__dict__) d.pop("_sa_instance_state", None) if no_nulls: @@ -905,6 +932,8 @@ class Admission(models.ScoDocModel): d[key] = 0 elif isinstance(col_type, sqlalchemy.Boolean): d[key] = False + if restrict: + d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs} return d @classmethod diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 3dcac667..e2c01695 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -184,7 +184,7 @@ class Evaluation(db.Model): # ScoDoc7 output_formators e_dict["evaluation_id"] = self.id e_dict["date_debut"] = self.date_debut.isoformat() if self.date_debut else None - e_dict["date_fin"] = self.date_debut.isoformat() if self.date_fin else None + e_dict["date_fin"] = self.date_fin.isoformat() if self.date_fin else None e_dict["numero"] = self.numero or 0 e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids } @@ -428,8 +428,8 @@ class Evaluation(db.Model): def get_ue_poids_str(self) -> str: """string describing poids, for excel cells and pdfs - Note: si les poids ne sont pas initialisés (poids par défaut), - ils ne sont pas affichés. + Note: les poids nuls ou non initialisés (poids par défaut), + ne sont pas affichés. """ # restreint aux UE du semestre dans lequel est cette évaluation # au cas où le module ait changé de semestre et qu'il reste des poids @@ -440,7 +440,7 @@ class Evaluation(db.Model): for p in sorted( self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme) ) - if evaluation_semestre_idx == p.ue.semestre_idx + if evaluation_semestre_idx == p.ue.semestre_idx and (p.poids or 0) > 0 ] ) @@ -584,20 +584,10 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict): if date_debut and date_fin: duration = data["date_fin"] - data["date_debut"] if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION: - raise ScoValueError("Heures de l'évaluation incohérentes !") - # # --- heures - # heure_debut = data.get("heure_debut", None) - # if heure_debut and not isinstance(heure_debut, datetime.time): - # if date_format == "dmy": - # data["heure_debut"] = heure_to_time(heure_debut) - # else: # ISO - # data["heure_debut"] = datetime.time.fromisoformat(heure_debut) - # heure_fin = data.get("heure_fin", None) - # if heure_fin and not isinstance(heure_fin, datetime.time): - # if date_format == "dmy": - # data["heure_fin"] = heure_to_time(heure_fin) - # else: # ISO - # data["heure_fin"] = datetime.time.fromisoformat(heure_fin) + raise ScoValueError( + "Heures de l'évaluation incohérentes !", + dest_url="javascript:history.back();", + ) def heure_to_time(heure: str) -> datetime.time: diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 4506eed0..32295816 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -187,7 +187,7 @@ class FormSemestre(db.Model): def get_formsemestre( cls, formsemestre_id: int | str, dept_id: int = None ) -> "FormSemestre": - """ "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant""" + """FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant""" if not isinstance(formsemestre_id, int): try: formsemestre_id = int(formsemestre_id) diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index b674ed99..9cb168eb 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -2,6 +2,7 @@ """ScoDoc models: moduleimpls """ import pandas as pd +from flask import abort, g from flask_sqlalchemy.query import Query from app import db @@ -82,6 +83,23 @@ class ModuleImpl(db.Model): df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids) return evaluations_poids + @classmethod + def get_modimpl(cls, moduleimpl_id: int | str, dept_id: int = None) -> "ModuleImpl": + """FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant.""" + from app.models.formsemestre import FormSemestre + + if not isinstance(moduleimpl_id, int): + try: + moduleimpl_id = int(moduleimpl_id) + except (TypeError, ValueError): + abort(404, "moduleimpl_id invalide") + if g.scodoc_dept: + dept_id = dept_id if dept_id is not None else g.scodoc_dept_id + query = cls.query.filter_by(id=moduleimpl_id) + if dept_id is not None: + query = query.join(FormSemestre).filter_by(dept_id=dept_id) + return query.first_or_404() + def invalidate_evaluations_poids(self): """Invalide poids cachés""" df_cache.EvaluationsPoidsCache.delete(self.id) diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py new file mode 100644 index 00000000..d6416c2e --- /dev/null +++ b/app/pe/pe_jurype.py @@ -0,0 +1,1274 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +""" +Created on Fri Sep 9 09:15:05 2016 + +@author: barasc +""" + +# ---------------------------------------------------------- +# Ensemble des fonctions et des classes +# permettant les calculs preliminaires (hors affichage) +# a l'edition d'un jury de poursuites d'etudes +# ---------------------------------------------------------- + +import io +import os +from zipfile import ZipFile + +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import Formation, FormSemestre + +from app.scodoc.gen_tables import GenTable, SeqGenTable +import app.scodoc.sco_utils as scu +from app.scodoc import codes_cursus # codes_cursus.NEXT -> sem suivant +from app.scodoc import sco_etud +from app.scodoc import sco_formsemestre +from app.pe import pe_tagtable +from app.pe import pe_tools +from app.pe import pe_semestretag +from app.pe import pe_settag + + +# ---------------------------------------------------------------------------------------- +def comp_nom_semestre_dans_parcours(sem): + """Le nom a afficher pour titrer un semestre + par exemple: "semestre 2 FI 2015" + """ + formation: Formation = Formation.query.get_or_404(sem["formation_id"]) + parcours = codes_cursus.get_cursus_from_code(formation.type_parcours) + return "%s %s %s %s" % ( + parcours.SESSION_NAME, # eg "semestre" + sem["semestre_id"], # eg 2 + sem.get("modalite", ""), # eg FI ou FC + sem["annee_debut"], # eg 2015 + ) + + +# ---------------------------------------------------------------------------------------- +class JuryPE(object): + """Classe memorisant toutes les informations necessaires pour etablir un jury de PE. Modele + base sur NotesTable + + Attributs : - diplome : l'annee d'obtention du diplome DUT et du jury de PE (generalement fevrier XXXX) + - juryEtudDict : dictionnaire récapitulant les étudiants participant au jury PE (données administratives + + celles des semestres valides à prendre en compte permettant le calcul des moyennes ... + {'etudid : { 'nom', 'prenom', 'civilite', 'diplome', '', }} + Rq: il contient à la fois les étudiants qui vont être diplomés à la date prévue + et ceux qui sont éliminés (abandon, redoublement, ...) pour affichage alternatif + + """ + + # Variables de classe décrivant les aggrégats, leur ordre d'apparition temporelle et + # leur affichage dans les avis latex + PARCOURS = { + "S1": { + "aggregat": ["S1"], + "ordre": 1, + "affichage_court": "S1", + "affichage_long": "Semestre 1", + }, + "S2": { + "aggregat": ["S2"], + "ordre": 2, + "affichage_court": "S2", + "affichage_long": "Semestre 2", + }, + "S3": { + "aggregat": ["S3"], + "ordre": 4, + "affichage_court": "S3", + "affichage_long": "Semestre 3", + }, + "S4": { + "aggregat": ["S4"], + "ordre": 5, + "affichage_court": "S4", + "affichage_long": "Semestre 4", + }, + "1A": { + "aggregat": ["S1", "S2"], + "ordre": 3, + "affichage_court": "1A", + "affichage_long": "1ère année", + }, + "2A": { + "aggregat": ["S3", "S4"], + "ordre": 6, + "affichage_court": "2A", + "affichage_long": "2ème année", + }, + "3S": { + "aggregat": ["S1", "S2", "S3"], + "ordre": 7, + "affichage_court": "S1+S2+S3", + "affichage_long": "DUT du semestre 1 au semestre 3", + }, + "4S": { + "aggregat": ["S1", "S2", "S3", "S4"], + "ordre": 8, + "affichage_court": "DUT", + "affichage_long": "DUT (tout semestre inclus)", + }, + } + + # ------------------------------------------------------------------------------------------------------------------ + def __init__(self, semBase): + """ + Création d'une table PE sur la base d'un semestre selectionné. De ce semestre est déduit : + 1. l'année d'obtention du DUT, + 2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés. + + Args: + semBase: le dictionnaire sem donnant la base du jury + meme_programme: si True, impose un même programme pour tous les étudiants participant au jury, + si False, permet des programmes differents + """ + self.semTagDict = ( + {} + ) # Les semestres taggués à la base des calculs de moyenne par tag + self.setTagDict = ( + {} + ) # dictionnaire récapitulant les semTag impliqués dans le jury de la forme { 'formsemestre_id' : object Semestre_tag + self.promoTagDict = {} + + # L'année du diplome + self.diplome = get_annee_diplome_semestre(semBase) + + # Un zip où ranger les fichiers générés: + self.NOM_EXPORT_ZIP = "Jury_PE_%s" % self.diplome + self.zipdata = io.BytesIO() + self.zipfile = ZipFile(self.zipdata, "w") + + # + self.ETUDINFO_DICT = {} # Les infos sur les étudiants + self.PARCOURSINFO_DICT = {} # Les parcours des étudiants + self.syntheseJury = {} # Le jury de synthèse + + self.semestresDeScoDoc = sco_formsemestre.do_formsemestre_list() + + # Calcul du jury PE + self.exe_calculs_juryPE(semBase) + self.synthetise_juryPE() + + # Export des données => mode 1 seule feuille -> supprimé + # filename = self.NOM_EXPORT_ZIP + "jurySyntheseDict_" + str(self.diplome) + '.xls' + # self.xls = self.table_syntheseJury(mode="singlesheet") + # self.add_file_to_zip(filename, self.xls.excel()) + + # Fabrique 1 fichier excel résultat avec 1 seule feuille => trop gros + filename = self.NOM_EXPORT_ZIP + "_jurySyntheseDict" + scu.XLSX_SUFFIX + self.xlsV2 = self.table_syntheseJury(mode="multiplesheet") + if self.xlsV2: + self.add_file_to_zip(filename, self.xlsV2.excel()) + + # Pour debug + # self.syntheseJury = pe_tools.JURY_SYNTHESE_POUR_DEBUG #Un dictionnaire fictif pour debug + + # ------------------------------------------------------------------------------------------------------------------ + def add_file_to_zip(self, filename, data, path=""): + """Add a file to our zip + All files under NOM_EXPORT_ZIP/ + path may specify a subdirectory + """ + path_in_zip = os.path.join(self.NOM_EXPORT_ZIP, path, filename) + self.zipfile.writestr(path_in_zip, data) + + # ------------------------------------------------------------------------------------------------------------------ + def get_zipped_data(self): + """returns file-like data with a zip of all generated (CSV) files. + Reset file cursor at the beginning ! + """ + if self.zipfile: + self.zipfile.close() + self.zipfile = None + self.zipdata.seek(0) + return self.zipdata + + # **************************************************************************************************************** # + # Lancement des différentes actions permettant le calcul du jury PE + # **************************************************************************************************************** # + def exe_calculs_juryPE(self, semBase): + # Liste des étudiants à traiter pour identifier ceux qui seront diplômés + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + "*** Recherche et chargement des étudiants diplômés en %d" + % (self.diplome) + ) + self.get_etudiants_in_jury( + semBase, avec_meme_formation=False + ) # calcul des coSemestres + + # Les semestres impliqués (ceux valides pour les étudiants à traiter) + # ------------------------------------------------------------------- + if pe_tools.PE_DEBUG: + pe_tools.pe_print("*** Création des semestres taggués") + self.get_semtags_in_jury() + if pe_tools.PE_DEBUG: + for semtag in self.semTagDict.values(): # Export + filename = self.NOM_EXPORT_ZIP + semtag.nom + ".csv" + self.zipfile.writestr(filename, semtag.str_tagtable()) + # self.export_juryPEDict() + + # Les moyennes sur toute la scolarité + # ----------------------------------- + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + "*** Création des moyennes sur différentes combinaisons de semestres et différents groupes d'étudiant" + ) + self.get_settags_in_jury() + if pe_tools.PE_DEBUG: + for settagdict in self.setTagDict.values(): # Export + for settag in settagdict.values(): + filename = self.NOM_EXPORT_ZIP + semtag.nom + ".csv" + self.zipfile.writestr(filename, semtag.str_tagtable()) + # self.export_juryPEDict() + + # Les interclassements + # -------------------- + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + "*** Création des interclassements au sein de la promo sur différentes combinaisons de semestres" + ) + self.get_promotags_in_jury() + + # **************************************************************************************************************** # + # Fonctions relatives à la liste des étudiants à prendre en compte dans le jury + # **************************************************************************************************************** # + + # ------------------------------------------------------------------------------------------------------------------ + def get_etudiants_in_jury(self, semBase, avec_meme_formation=False): + """ + Calcule la liste des étudiants à prendre en compte dans le jury et la renvoie sous la forme + """ + # Les cosemestres donnant lieu à meme année de diplome + coSems = get_cosemestres_diplomants( + semBase, avec_meme_formation=avec_meme_formation + ) # calcul des coSemestres + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + "1) Recherche des coSemestres -> %d trouvés" % len(coSems) + ) + + # Les étudiants inscrits dans les cosemestres + if pe_tools.PE_DEBUG: + pe_tools.pe_print("2) Liste des étudiants dans les différents co-semestres") + listEtudId = self.get_etudiants_dans_semestres( + coSems + ) # étudiants faisant parti des cosemestres + if pe_tools.PE_DEBUG: + pe_tools.pe_print(" => %d étudiants trouvés" % len(listEtudId)) + + # L'analyse des parcours étudiants pour déterminer leur année effective de diplome avec prise en compte des redoublements, des abandons, .... + if pe_tools.PE_DEBUG: + pe_tools.pe_print("3) Analyse des parcours individuels des étudiants") + + for no_etud, etudid in enumerate(listEtudId): + self.add_etudiants(etudid) + if pe_tools.PE_DEBUG: + if (no_etud + 1) % 10 == 0: + pe_tools.pe_print((no_etud + 1), " ", end="") + pe_tools.pe_print() + + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + " => %d étudiants à diplômer en %d" + % (len(self.get_etudids_du_jury()), self.diplome) + ) + pe_tools.pe_print( + " => %d étudiants éliminer pour abandon" + % (len(listEtudId) - len(self.get_etudids_du_jury())) + ) + + # ------------------------------------------------------------------------------------------------------------------ + + # ------------------------------------------------------------------------------------------------------------------ + def get_etudiants_dans_semestres(self, semsListe): + """Renvoie la liste des etudid des etudiants inscrits à l'un des semestres de la liste fournie en paramètre + en supprimant les doublons (i.e. un même étudiant qui apparaîtra 2 fois)""" + + etudiants = [] + for sem in semsListe: # pour chacun des semestres de la liste + nt = self.get_cache_notes_d_un_semestre(sem["formsemestre_id"]) + etudiantsDuSemestre = ( + nt.get_etudids() + ) # identification des etudiants du semestre + + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + " --> chargement du semestre %s : %d etudiants " + % (sem["formsemestre_id"], len(etudiantsDuSemestre)) + ) + etudiants.extend(etudiantsDuSemestre) + + return list(set(etudiants)) # suppression des doublons + + # ------------------------------------------------------------------------------------------------------------------ + def get_etudids_du_jury(self, ordre="aucun"): + """Renvoie la liste de tous les étudiants (concrètement leur etudid) + participant au jury c'est à dire, ceux dont la date du 'jury' est self.diplome + et n'ayant pas abandonné. + Si l'ordre est précisé, donne une liste etudid dont le nom, prenom trié par ordre alphabétique + """ + etudids = [ + etudid + for (etudid, donnees) in self.PARCOURSINFO_DICT.items() + if donnees["diplome"] == self.diplome and donnees["abandon"] == False + ] + if ordre == "alphabetique": # Tri alphabétique + etudidsAvecNom = [ + (etudid, etud["nom"] + "/" + etud["prenom"]) + for (etudid, etud) in self.PARCOURSINFO_DICT.items() + if etudid in etudids + ] + etudidsAvecNomTrie = sorted(etudidsAvecNom, key=lambda col: col[1]) + etudids = [etud[0] for etud in etudidsAvecNomTrie] + return etudids + + # ------------------------------------------------------------------------------------------------------------------ + + # ------------------------------------------------------------------------------------------------------------------ + def add_etudiants(self, etudid): + """Ajoute un étudiant (via son etudid) au dictionnaire de synthèse jurydict. + L'ajout consiste à : + > insérer une entrée pour l'étudiant en mémorisant ses infos (get_etudInfo), + avec son nom, prénom, etc... + > à analyser son parcours, pour vérifier s'il n'a pas abandonné l'IUT en cours de route => clé abandon + > à chercher ses semestres valides (formsemestre_id) et ses années valides (formannee_id), + c'est à dire ceux pour lesquels il faudra prendre en compte ses notes dans les calculs de moyenne (type 1A=S1+S2/2) + """ + + if etudid not in self.PARCOURSINFO_DICT: + etud = self.get_cache_etudInfo_d_un_etudiant( + etudid + ) # On charge les données de l'étudiant + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print(etud["nom"] + " " + etud["prenom"], end="") + + self.PARCOURSINFO_DICT[etudid] = { + "etudid": etudid, # les infos sur l'étudiant + "nom": etud["nom"], # Ajout à la table jury + } + + # Analyse du parcours de l'étudiant + + # Sa date prévisionnelle de diplome + self.PARCOURSINFO_DICT[etudid][ + "diplome" + ] = self.calcul_anneePromoDUT_d_un_etudiant(etudid) + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print( + "promo=" + str(self.PARCOURSINFO_DICT[etudid]["diplome"]), end="" + ) + + # Est-il réorienté ou démissionnaire ? + self.PARCOURSINFO_DICT[etudid][ + "abandon" + ] = self.est_un_etudiant_reoriente_ou_demissionnaire(etudid) + + # A-t-il arrêté de lui-même sa formation avant la fin ? + etatD = self.est_un_etudiant_disparu(etudid) + if etatD == True: + self.PARCOURSINFO_DICT[etudid]["abandon"] = True + # dans le jury ne seront traités que les étudiants ayant la date attendue de diplome et n'ayant pas abandonné + + # Quels sont ses semestres validant (i.e ceux dont les notes doivent être prises en compte pour le jury) + # et s'ils existent quelles sont ses notes utiles ? + sesFormsemestre_idValidants = [ + self.get_Fid_d_un_Si_valide_d_un_etudiant(etudid, nom_sem) + for nom_sem in JuryPE.PARCOURS["4S"][ + "aggregat" + ] # Recherche du formsemestre_id de son Si valide (ou a défaut en cours) + ] + for i, nom_sem in enumerate(JuryPE.PARCOURS["4S"]["aggregat"]): + fid = sesFormsemestre_idValidants[i] + self.PARCOURSINFO_DICT[etudid][nom_sem] = fid # ['formsemestre_id'] + if fid != None and pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print(nom_sem + "=" + str(fid), end="") + # self.get_moyennesEtClassements_par_semestre_d_un_etudiant( etudid, fid ) + + # Quelles sont ses années validantes ('1A', '2A') et ses parcours (3S, 4S) validants ? + for parcours in ["1A", "2A", "3S", "4S"]: + lesSemsDuParcours = JuryPE.PARCOURS[parcours][ + "aggregat" + ] # les semestres du parcours : par ex. ['S1', 'S2', 'S3'] + lesFidsValidantDuParcours = [ + sesFormsemestre_idValidants[ + JuryPE.PARCOURS["4S"]["aggregat"].index(nom_sem) + ] + for nom_sem in lesSemsDuParcours # par ex. ['SEM4532', 'SEM567', ...] + ] + parcours_incomplet = ( + sum([fid == None for fid in lesFidsValidantDuParcours]) > 0 + ) + + if not parcours_incomplet: + self.PARCOURSINFO_DICT[etudid][ + parcours + ] = lesFidsValidantDuParcours[-1] + else: + self.PARCOURSINFO_DICT[etudid][parcours] = None + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print( + parcours + "=" + str(self.PARCOURSINFO_DICT[etudid][parcours]), + end="", + ) + + # if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + # print + + # ------------------------------------------------------------------------------------------------------------------ + def est_un_etudiant_reoriente_ou_demissionnaire(self, etudid): + """Renvoie True si l'étudiant est réorienté (NAR) ou démissionnaire (DEM)""" + from app.scodoc import sco_report + + reponse = False + etud = self.get_cache_etudInfo_d_un_etudiant(etudid) + (_, parcours) = sco_report.get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) + if ( + len(codes_cursus.CODES_SEM_REO & set(parcours.values())) > 0 + ): # Eliminé car NAR apparait dans le parcours + reponse = True + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print(" -> à éliminer car réorienté (NAR)") + if "DEM" in list(parcours.values()): # Eliminé car DEM + reponse = True + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print(" -> à éliminer car DEM") + return reponse + + # ------------------------------------------------------------------------------------------------------------------ + def est_un_etudiant_disparu(self, etudid): + """Renvoie True si l'étudiant n'a pas achevé la formation à l'IUT et a disparu des listes, sans + pour autant avoir été indiqué NAR ou DEM ; recherche son dernier semestre validé et regarde s'il + n'existe pas parmi les semestres existants dans scodoc un semestre postérieur (en terme de date de + début) de n° au moins égal à celui de son dernier semestre valide dans lequel il aurait pu + s'inscrire mais ne l'a pas fait.""" + sessems = self.get_semestresDUT_d_un_etudiant( + etudid + ) # les semestres de l'étudiant + sonDernierSidValide = self.get_dernier_semestre_id_valide_d_un_etudiant(etudid) + + sesdates = [ + pe_tagtable.conversionDate_StrToDate(sem["date_fin"]) for sem in sessems + ] # association 1 date -> 1 semestrePE pour les semestres de l'étudiant + if sesdates: + lastdate = max(sesdates) # date de fin de l'inscription la plus récente + else: + return False + + # if PETable.AFFICHAGE_DEBUG_PE == True : pe_tools.pe_print(" derniere inscription = ", lastDateSem) + + if sonDernierSidValide is None: + # si l'étudiant n'a validé aucun semestre, les prend tous ? (à vérifier) + semestresSuperieurs = self.semestresDeScoDoc + else: + semestresSuperieurs = [ + sem + for sem in self.semestresDeScoDoc + if sem["semestre_id"] > sonDernierSidValide + ] # Semestre de rang plus élevé que son dernier sem valide + datesDesSemestresSuperieurs = [ + pe_tagtable.conversionDate_StrToDate(sem["date_debut"]) + for sem in semestresSuperieurs + ] + datesDesSemestresPossibles = [ + date_deb for date_deb in datesDesSemestresSuperieurs if date_deb >= lastdate + ] # date de debut des semestres possibles postérieur au dernier semestre de l'étudiant et de niveau plus élevé que le dernier semestre valide de l'étudiant + if ( + len(datesDesSemestresPossibles) > 0 + ): # etudiant ayant disparu de la circulation + # if PETable.AFFICHAGE_DEBUG_PE == True : + # pe_tools.pe_print(" -> à éliminer car des semestres où il aurait pu s'inscrire existent ") + # pe_tools.pe_print(pe_tools.print_semestres_description( datesDesSemestresPossibles.values() )) + return True + else: + return False + + # ------------------------------------------------------------------------------------------------------------------ + + # ------------------------------------------------------------------------------------------------------------------ + def get_dernier_semestre_id_valide_d_un_etudiant(self, etudid): + """Renvoie le n° (semestre_id) du dernier semestre validé par un étudiant fourni par son etudid + et None si aucun semestre n'a été validé + """ + from app.scodoc import sco_report + + etud = self.get_cache_etudInfo_d_un_etudiant(etudid) + (code, parcours) = sco_report.get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) # description = '1234:A', parcours = {1:ADM, 2:NAR, ...} + sonDernierSemestreValide = max( + [ + int(cle) + for (cle, code) in parcours.items() + if code in codes_cursus.CODES_SEM_VALIDES + ] + + [0] + ) # n° du dernier semestre valide, 0 sinon + return sonDernierSemestreValide if sonDernierSemestreValide > 0 else None + + # ------------------------------------------------------------------------------------------------------------------ + + # ------------------------------------------------------------------------------------------------------------------ + def get_Fid_d_un_Si_valide_d_un_etudiant(self, etudid, nom_semestre): + """Récupère le formsemestre_id valide d'un étudiant fourni son etudid à un semestre DUT de n° semestre_id + donné. Si le semestre est en cours (pas encore de jury), renvoie le formsemestre_id actuel. + """ + semestre_id = JuryPE.PARCOURS["4S"]["aggregat"].index(nom_semestre) + 1 + sesSi = self.get_semestresDUT_d_un_etudiant( + etudid, semestre_id + ) # extrait uniquement les Si par ordre temporel décroissant + + if len(sesSi) > 0: # S'il a obtenu au moins une note + # mT = sesMoyennes[0] + leFid = sesSi[0]["formsemestre_id"] + for i, sem in enumerate( + sesSi + ): # Parcours des éventuels semestres précédents + nt = self.get_cache_notes_d_un_semestre(sem["formsemestre_id"]) + dec = nt.get_etud_decision_sem( + etudid + ) # quelle est la décision du jury ? + if dec and (dec["code"] in codes_cursus.CODES_SEM_VALIDES): + # isinstance( sesMoyennes[i+1], float) and + # mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide" + leFid = sem["formsemestre_id"] + else: + leFid = None + return leFid + + # **************************************************************************************************************** # + # Traitements des semestres impliqués dans le jury + # **************************************************************************************************************** # + + # ------------------------------------------------------------------------------------------------------------------ + def get_semtags_in_jury(self): + """ + Créé les semestres tagués relatifs aux résultats des étudiants à prendre en compte dans le jury. + Calcule les moyennes et les classements de chaque semestre par tag et les statistiques de ces semestres. + """ + lesFids = self.get_formsemestreids_du_jury( + self.get_etudids_du_jury(), liste_semestres=["S1", "S2", "S3", "S4"] + ) + for i, fid in enumerate(lesFids): + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + "%d) Semestre taggué %s (avec classement dans groupe)" + % (i + 1, fid) + ) + self.add_semtags_in_jury(fid) + + # ------------------------------------------------------------------------------------------------------------------ + def add_semtags_in_jury(self, fid): + """Crée si nécessaire un semtag et le mémorise dans self.semTag ; + charge également les données des nouveaux étudiants qui en font partis. + """ + # Semestre taggué avec classement dans le groupe + if fid not in self.semTagDict: + nt = self.get_cache_notes_d_un_semestre(fid) + + # Création du semestres + self.semTagDict[fid] = pe_semestretag.SemestreTag( + nt, nt.sem + ) # Création du pesemestre associé + self.semTagDict[fid].comp_data_semtag() + lesEtudids = self.semTagDict[fid].get_etudids() + + lesEtudidsManquants = [] + for etudid in lesEtudids: + if ( + etudid not in self.PARCOURSINFO_DICT + ): # Si l'étudiant n'a pas été pris en compte dans le jury car déjà diplômé ou redoublant + lesEtudidsManquants.append(etudid) + # self.get_cache_etudInfo_d_un_etudiant(etudid) + self.add_etudiants( + etudid + ) # Ajoute les élements de parcours de l'étudiant + + nbinscrit = self.semTagDict[fid].get_nbinscrits() + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + " - %d étudiants classés " % (nbinscrit) + + ": " + + ",".join( + [etudid for etudid in self.semTagDict[fid].get_etudids()] + ) + ) + if lesEtudidsManquants: + pe_tools.pe_print( + " - dont %d étudiants manquants ajoutés aux données du jury" + % (len(lesEtudidsManquants)) + + ": " + + ", ".join(lesEtudidsManquants) + ) + pe_tools.pe_print(" - Export csv") + filename = self.NOM_EXPORT_ZIP + self.semTagDict[fid].nom + ".csv" + self.zipfile.writestr(filename, self.semTagDict[fid].str_tagtable()) + + # ---------------------------------------------------------------------------------------------------------------- + def get_formsemestreids_du_jury(self, etudids, liste_semestres="4S"): + """Renvoie la liste des formsemestre_id validants des étudiants en parcourant les semestres valides des étudiants mémorisés dans + self.PARCOURSINFO_DICT. + Les étudiants sont identifiés par leur etudic donnés dans la liste etudids (généralement self.get_etudids_in_jury() ). + La liste_semestres peut être une liste ou une chaine de caractères parmi : + * None => tous les Fids validant + * 'Si' => le ième 1 semestre + * 'iA' => l'année i = ['S1, 'S2'] ou ['S3', 'S4'] + * '3S', '4S' => fusion des semestres + * [ 'Si', 'iA' , ... ] => une liste combinant les formats précédents + """ + champs_possibles = list(JuryPE.PARCOURS.keys()) + if ( + not isinstance(liste_semestres, list) + and not isinstance(liste_semestres, str) + and liste_semestres not in champs_possibles + ): + raise ValueError( + "Probleme de paramètres d'appel dans pe_jurype.JuryPE.get_formsemestreids_du_jury" + ) + + if isinstance(liste_semestres, list): + res = [] + for elmt in liste_semestres: + res.extend(self.get_formsemestreids_du_jury(etudids, elmt)) + return list(set(res)) + + # si liste_sem est un nom de parcours + nom_sem = liste_semestres + # if nom_sem in ['1A', '2A', '3S', '4S'] : + # return self.get_formsemestreids_du_jury(etudids, JuryPE.PARCOURS[nom_sem] ) + # else : + fids = { + self.PARCOURSINFO_DICT[etudid][nom_sem] + for etudid in etudids + if self.PARCOURSINFO_DICT[etudid][nom_sem] != None + } + + return list(fids) + + # **************************************************************************************************************** # + # Traitements des parcours impliquées dans le jury + # **************************************************************************************************************** # + + # # ---------------------------------------------------------------------------------------------------------------- + # def get_antags_in_jury(self, avec_affichage_debug=True ): + # """Construit les settag associés aux années 1A et 2A du jury""" + # lesAnnees = {'1A' : ['S1', 'S2'], '2A' : ['S3', 'S4'] } + # for nom_annee in lesAnnees: + # lesAidDesAnnees = self.get_anneeids_du_jury(annee= nom_annee) # les annee_ids des étudiants du jury + # for aid in lesAidDesAnnees: + # fidSemTagFinal = JuryPE.convert_aid_en_fid( aid ) + # lesEtudisDelAnnee = self.semTagDict[ fidSemTagFinal ].get_etudids() # les etudiants sont ceux inscrits dans le semestre final de l'année + # parcoursDesEtudiants = { etudid : self.PARCOURSINFO_DICT[etudid] for etudid in lesEtudisDelAnnee } # les parcours des etudid aka quels semestres sont à prendre en compte + # + # lesFidsDesEtudiants = self.get_formsemestreids_du_jury(lesEtudisDelAnnee, nom_annee) # les formsemestres_id à prendre en compte pour les moyennes + # # Manque-t-il des semtag associés ; si oui, les créé + # pe_tools.pe_print(aid, lesFidsDesEtudiants) + # for fid in lesFidsDesEtudiants: + # self.add_semtags_in_jury(fid, avec_affichage_debug=avec_affichage_debug) + # lesSemTagDesEtudiants = { fid: self.semTagDict[fid] for fid in lesFidsDesEtudiants } + # + # # Tous les semtag nécessaires pour ses étudiants avec ajout éventuel s'ils n'ont pas été chargés + # pe_tools.pe_print(" -> Création de l'année tagguée " + str( aid )) + # #settag_id, short_name, listeEtudId, groupe, listeSemAAggreger, ParcoursEtudDict, SemTagDict, with_comp_moy=True) + # self.anTagDict[ aid ] = pe_settag.SetTag( aid, "Annee " + self.semTagDict[fidSemTagFinal].short_name, \ + # lesEtudisDelAnnee, 'groupe', lesAnnees[ nom_annee ], parcoursDesEtudiants, lesSemTagDesEtudiants ) + # self.anTagDict[ aid ].comp_data_settag() # calcul les moyennes + + # **************************************************************************************************************** # + # Traitements des moyennes sur différentes combinaisons de parcours 1A, 2A, 3S et 4S, + # impliquées dans le jury + # **************************************************************************************************************** # + + def get_settags_in_jury(self): + """Calcule les moyennes sur la totalité du parcours (S1 jusqu'à S3 ou S4) + en classant les étudiants au sein du semestre final du parcours (même S3, même S4, ...) + """ + + # Par groupe : + # combinaisons = { 'S1' : ['S1'], 'S2' : ['S2'], 'S3' : ['S3'], 'S4' : ['S4'], \ + # '1A' : ['S1', 'S2'], '2A' : ['S3', 'S4'], + # '3S' : ['S1', 'S2', 'S3'], '4S' : ['S1', 'S2', 'S3', 'S4'] } + + # ---> sur 2 parcours DUT (cas S3 fini, cas S4 fini) + combinaisons = ["1A", "2A", "3S", "4S"] + for i, nom in enumerate(combinaisons): + parcours = JuryPE.PARCOURS[nom][ + "aggregat" + ] # La liste des noms de semestres (S1, S2, ...) impliqués dans l'aggrégat + + # Recherche des parcours possibles par le biais de leur Fid final + fids_finaux = self.get_formsemestreids_du_jury( + self.get_etudids_du_jury(), nom + ) # les formsemestre_ids validant finaux des étudiants du jury + + if len(fids_finaux) > 0: # S'il existe des parcours validant + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 1: + pe_tools.pe_print("%d) Fusion %s avec" % (i + 1, nom)) + + if nom not in self.setTagDict: + self.setTagDict[nom] = {} + + for fid in fids_finaux: + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 1: + pe_tools.pe_print(" - semestre final %s" % (fid)) + settag = pe_settag.SetTag( + nom, parcours=parcours + ) # Le set tag fusionnant les données + etudiants = self.semTagDict[ + fid + ].get_etudids() # Les étudiants du sem final + + # ajoute les étudiants au semestre + settag.set_Etudiants( + etudiants, + self.PARCOURSINFO_DICT, + self.ETUDINFO_DICT, + nom_sem_final=self.semTagDict[fid].nom, + ) + + # manque-t-il des semestres ? Si oui, les ajoute au jurype puis au settag + for ffid in settag.get_Fids_in_settag(): + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 1: + pe_tools.pe_print( + " -> ajout du semestre tagué %s" % (ffid) + ) + self.add_semtags_in_jury(ffid) + settag.set_SemTagDict( + self.semTagDict + ) # ajoute les semestres au settag + + settag.comp_data_settag() # Calcul les moyennes, les rangs, .. + + self.setTagDict[nom][fid] = settag # Mémorise le résultat + + else: + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 1: + pe_tools.pe_print("%d) Pas de fusion %s possible" % (i + 1, nom)) + + def get_promotags_in_jury(self): + """Calcule les aggrégats en interclassant les étudiants du jury (les moyennes ont déjà été calculées en amont)""" + + lesEtudids = self.get_etudids_du_jury() + + for i, nom in enumerate(JuryPE.PARCOURS.keys()): + settag = pe_settag.SetTagInterClasse(nom, diplome=self.diplome) + nbreEtudInscrits = settag.set_Etudiants( + lesEtudids, self.PARCOURSINFO_DICT, self.ETUDINFO_DICT + ) + if nbreEtudInscrits > 0: + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + "%d) %s avec interclassement sur la promo" % (i + 1, nom) + ) + if nom in ["S1", "S2", "S3", "S4"]: + settag.set_SetTagDict(self.semTagDict) + else: # cas des aggrégats + settag.set_SetTagDict(self.setTagDict[nom]) + settag.comp_data_settag() + self.promoTagDict[nom] = settag + else: + if pe_tools.PE_DEBUG: + pe_tools.pe_print( + "%d) Pas d'interclassement %s sur la promo faute de notes" + % (i + 1, nom) + ) + + # **************************************************************************************************************** # + # Méthodes pour la synthèse du juryPE + # ***************************************************************************************************************** + def synthetise_juryPE(self): + """Synthétise tous les résultats du jury PE dans un dictionnaire""" + self.syntheseJury = {} + for etudid in self.get_etudids_du_jury(): + etudinfo = self.ETUDINFO_DICT[etudid] + self.syntheseJury[etudid] = { + "nom": etudinfo["nom"], + "prenom": etudinfo["prenom"], + "civilite": etudinfo["civilite"], + "civilite_str": etudinfo["civilite_str"], + "age": str(pe_tools.calcul_age(etudinfo["date_naissance"])), + "lycee": etudinfo["nomlycee"] + + ( + " (" + etudinfo["villelycee"] + ")" + if etudinfo["villelycee"] != "" + else "" + ), + "bac": etudinfo["bac"], + "code_nip": etudinfo["code_nip"], # pour la photo + "entree": self.get_dateEntree(etudid), + "promo": self.diplome, + } + # Le parcours + self.syntheseJury[etudid]["parcours"] = self.get_parcoursIUT( + etudid + ) # liste des semestres + self.syntheseJury[etudid]["nbSemestres"] = len( + self.syntheseJury[etudid]["parcours"] + ) # nombre de semestres + + # Ses résultats + for nom in JuryPE.PARCOURS: # S1, puis S2, puis 1A + # dans le groupe : la table tagguée dans les semtag ou les settag si aggrégat + self.syntheseJury[etudid][nom] = {"groupe": {}, "promo": {}} + if ( + self.PARCOURSINFO_DICT[etudid][nom] != None + ): # Un parcours valide existe + if nom in ["S1", "S2", "S3", "S4"]: + tagtable = self.semTagDict[self.PARCOURSINFO_DICT[etudid][nom]] + else: + tagtable = self.setTagDict[nom][ + self.PARCOURSINFO_DICT[etudid][nom] + ] + for tag in tagtable.get_all_tags(): + self.syntheseJury[etudid][nom]["groupe"][ + tag + ] = tagtable.get_resultatsEtud( + tag, etudid + ) # Le tuple des résultats + + # interclassé dans la promo + tagtable = self.promoTagDict[nom] + for tag in tagtable.get_all_tags(): + self.syntheseJury[etudid][nom]["promo"][ + tag + ] = tagtable.get_resultatsEtud(tag, etudid) + + def get_dateEntree(self, etudid): + """Renvoie l'année d'entrée de l'étudiant à l'IUT""" + # etudinfo = self.ETUDINFO_DICT[etudid] + semestres = self.get_semestresDUT_d_un_etudiant(etudid) + if semestres: + # le 1er sem à l'IUT + return semestres[0]["annee_debut"] + else: + return "" + + def get_parcoursIUT(self, etudid): + """Renvoie une liste d'infos sur les semestres du parcours d'un étudiant""" + # etudinfo = self.ETUDINFO_DICT[etudid] + sems = self.get_semestresDUT_d_un_etudiant(etudid) + + infos = [] + for sem in sems: + nomsem = comp_nom_semestre_dans_parcours(sem) + infos.append( + { + "nom_semestre_dans_parcours": nomsem, + "titreannee": sem["titreannee"], + "formsemestre_id": sem["formsemestre_id"], # utile dans le futur ? + } + ) + return infos + + # **************************************************************************************************************** # + # Méthodes d'affichage pour debug + # **************************************************************************************************************** # + def str_etudiants_in_jury(self, delim=";"): + # En tete: + entete = ["Id", "Nom", "Abandon", "Diplome"] + for nom_sem in ["S1", "S2", "S3", "S4", "1A", "2A", "3S", "4S"]: + entete += [nom_sem, "descr"] + chaine = delim.join(entete) + "\n" + + for etudid in self.PARCOURSINFO_DICT: + donnees = self.PARCOURSINFO_DICT[etudid] + # pe_tools.pe_print(etudid, donnees) + # les infos générales + descr = [ + etudid, + donnees["nom"], + str(donnees["abandon"]), + str(donnees["diplome"]), + ] + + # les semestres + for nom_sem in ["S1", "S2", "S3", "S4", "1A", "2A", "3S", "4S"]: + table = ( + self.semTagDict[donnees[nom_sem]].nom + if donnees[nom_sem] in self.semTagDict + else "manquant" + ) + descr += [ + donnees[nom_sem] if donnees[nom_sem] != None else "manquant", + table, + ] + + chaine += delim.join(descr) + "\n" + return chaine + + # + def export_juryPEDict(self): + """Export csv de self.PARCOURSINFO_DICT""" + fichier = "juryParcoursDict_" + str(self.diplome) + pe_tools.pe_print(" -> Export de " + fichier) + filename = self.NOM_EXPORT_ZIP + fichier + ".csv" + self.zipfile.writestr(filename, self.str_etudiants_in_jury()) + + def get_allTagForAggregat(self, nom_aggregat): + """Extrait du dictionnaire syntheseJury la liste des tags d'un semestre ou + d'un aggrégat donné par son nom (S1, S2, S3 ou S4, 1A, ...). Renvoie [] si aucun tag. + """ + taglist = set() + for etudid in self.get_etudids_du_jury(): + taglist = taglist.union( + set(self.syntheseJury[etudid][nom_aggregat]["groupe"].keys()) + ) + taglist = taglist.union( + set(self.syntheseJury[etudid][nom_aggregat]["promo"].keys()) + ) + return list(taglist) + + def get_allTagInSyntheseJury(self): + """Extrait tous les tags du dictionnaire syntheseJury trié par ordre alphabétique. [] si aucun tag""" + allTags = set() + for nom in JuryPE.PARCOURS.keys(): + allTags = allTags.union(set(self.get_allTagForAggregat(nom))) + return sorted(list(allTags)) if len(allTags) > 0 else [] + + def table_syntheseJury(self, mode="singlesheet"): # was str_syntheseJury + """Table(s) du jury + mode: singlesheet ou multiplesheet pour export excel + """ + sT = SeqGenTable() # le fichier excel à générer + + # Les etudids des étudiants à afficher, triés par ordre alphabétiques de nom+prénom + donnees_tries = sorted( + [ + ( + etudid, + self.syntheseJury[etudid]["nom"] + + " " + + self.syntheseJury[etudid]["prenom"], + ) + for etudid in self.syntheseJury.keys() + ], + key=lambda c: c[1], + ) + etudids = [e[0] for e in donnees_tries] + if not etudids: # Si pas d'étudiants + T = GenTable( + columns_ids=["pas d'étudiants"], + rows=[], + titles={"pas d'étudiants": "pas d'étudiants"}, + html_sortable=True, + xls_sheet_name="dut", + ) + sT.add_genTable("dut", T) + return sT + + # Si des étudiants + maxParcours = max( + [self.syntheseJury[etudid]["nbSemestres"] for etudid in etudids] + ) + + infos = ["civilite", "nom", "prenom", "age", "nbSemestres"] + entete = ["etudid"] + entete.extend(infos) + entete.extend(["P%d" % i for i in range(1, maxParcours + 1)]) + champs = [ + "note", + "class groupe", + "class promo", + "min/moy/max groupe", + "min/moy/max promo", + ] + + # Les aggrégats à afficher par ordre tel que indiqué dans le dictionnaire parcours + aggregats = list(JuryPE.PARCOURS.keys()) # ['S1', 'S2', ..., '1A', '4S'] + aggregats = sorted( + aggregats, key=lambda t: JuryPE.PARCOURS[t]["ordre"] + ) # Tri des aggrégats + + if mode == "multiplesheet": + allSheets = ( + self.get_allTagInSyntheseJury() + ) # tous les tags de syntheseJuryDict + allSheets = sorted(allSheets) # Tri des tags par ordre alphabétique + for ( + sem + ) in aggregats: # JuryPE.PARCOURS.keys() -> ['S1', 'S2', ..., '1A', '4S'] + entete.extend(["%s %s" % (sem, champ) for champ in champs]) + else: # "singlesheet" + allSheets = ["singlesheet"] + for ( + sem + ) in aggregats: # JuryPE.PARCOURS.keys() -> ['S1', 'S2', ..., '1A', '4S'] + tags = self.get_allTagForAggregat(sem) + entete.extend( + ["%s %s %s" % (sem, tag, champ) for tag in tags for champ in champs] + ) + + columns_ids = entete # les id et les titres de colonnes sont ici identiques + titles = {i: i for i in columns_ids} + + for ( + sheet + ) in ( + allSheets + ): # Pour tous les sheets à générer (1 si singlesheet, autant que de tags si multiplesheet) + rows = [] + for etudid in etudids: + e = self.syntheseJury[etudid] + # Les info générales: + row = { + "etudid": etudid, + "civilite": e["civilite"], + "nom": e["nom"], + "prenom": e["prenom"], + "age": e["age"], + "nbSemestres": e["nbSemestres"], + } + # Les parcours: P1, P2, ... + n = 1 + for p in e["parcours"]: + row["P%d" % n] = p["titreannee"] + n += 1 + # if self.syntheseJury[etudid]['nbSemestres'] < maxParcours: + # descr += delim.join( ['']*( maxParcours -self.syntheseJury[etudid]['nbSemestres']) ) + delim + for sem in aggregats: # JuryPE.PARCOURS.keys(): + listeTags = ( + self.get_allTagForAggregat(sem) + if mode == "singlesheet" + else [sheet] + ) + for tag in listeTags: + if tag in self.syntheseJury[etudid][sem]["groupe"]: + resgroupe = self.syntheseJury[etudid][sem]["groupe"][ + tag + ] # tuple + else: + resgroupe = (None, None, None, None, None, None, None) + if tag in self.syntheseJury[etudid][sem]["promo"]: + respromo = self.syntheseJury[etudid][sem]["promo"][tag] + else: + respromo = (None, None, None, None, None, None, None) + + # note = "%2.2f" % resgroupe[0] if isinstance(resgroupe[0], float) else str(resgroupe[0]) + champ = ( + "%s %s " % (sem, tag) + if mode == "singlesheet" + else "%s " % (sem) + ) + row[champ + "note"] = scu.fmt_note(resgroupe[0]) + row[champ + "class groupe"] = "%s / %s" % ( + resgroupe[2], + resgroupe[3], + ) + row[champ + "class promo"] = "%s / %s" % ( + respromo[2], + respromo[3], + ) + row[champ + "min/moy/max groupe"] = "%s / %s / %s" % tuple( + scu.fmt_note(x) + for x in (resgroupe[6], resgroupe[4], resgroupe[5]) + ) + row[champ + "min/moy/max promo"] = "%s / %s / %s" % tuple( + scu.fmt_note(x) + for x in (respromo[6], respromo[4], respromo[5]) + ) + rows.append(row) + + T = GenTable( + columns_ids=columns_ids, + rows=rows, + titles=titles, + html_sortable=True, + xls_sheet_name=sheet, + ) + sT.add_genTable(sheet, T) + + if mode == "singlesheet": + return sT.get_genTable("singlesheet") + else: + return sT + + # **************************************************************************************************************** # + # Méthodes de classe pour gestion d'un cache de données accélérant les calculs / intérêt à débattre + # **************************************************************************************************************** # + + # ------------------------------------------------------------------------------------------------------------------ + def get_cache_etudInfo_d_un_etudiant(self, etudid): + """Renvoie les informations sur le parcours d'un étudiant soit en les relisant depuis + ETUDINFO_DICT si mémorisée soit en les chargeant et en les mémorisant + """ + if etudid not in self.ETUDINFO_DICT: + self.ETUDINFO_DICT[etudid] = sco_etud.get_etud_info( + etudid=etudid, filled=True + )[0] + return self.ETUDINFO_DICT[etudid] + + # ------------------------------------------------------------------------------------------------------------------ + + # ------------------------------------------------------------------------------------------------------------------ + def get_cache_notes_d_un_semestre(self, formsemestre_id: int) -> NotesTableCompat: + """Charge la table des notes d'un formsemestre""" + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + return res_sem.load_formsemestre_results(formsemestre) + + # ------------------------------------------------------------------------------------------------------------------ + + # ------------------------------------------------------------------------------------------------------------------ + def get_semestresDUT_d_un_etudiant(self, etudid, semestre_id=None): + """Renvoie la liste des semestres DUT d'un étudiant + pour un semestre_id (parmi 1,2,3,4) donné + en fonction de ses infos d'etud (cf. sco_etud.get_etud_info( etudid=etudid, filled=True)[0]), + les semestres étant triés par ordre décroissant. + Si semestre_id == None renvoie tous les semestres""" + etud = self.get_cache_etudInfo_d_un_etudiant(etudid) + if semestre_id == None: + sesSems = [sem for sem in etud["sems"] if 1 <= sem["semestre_id"] <= 4] + else: + sesSems = [sem for sem in etud["sems"] if sem["semestre_id"] == semestre_id] + return sesSems + + # ********************************************** + def calcul_anneePromoDUT_d_un_etudiant(self, etudid) -> int: + """Calcule et renvoie la date de diplome prévue pour un étudiant fourni avec son etudid + en fonction de ses semestres de scolarisation""" + semestres = self.get_semestresDUT_d_un_etudiant(etudid) + if semestres: + return max([get_annee_diplome_semestre(sem) for sem in semestres]) + else: + return None + + # ********************************************* + # Fonctions d'affichage pour debug + def get_resultat_d_un_etudiant(self, etudid): + chaine = "" + for nom_sem in ["S1", "S2", "S3", "S4"]: + semtagid = self.PARCOURSINFO_DICT[etudid][ + nom_sem + ] # le formsemestre_id du semestre taggué de l'étudiant + semtag = self.semTagDict[semtagid] + chaine += "Semestre " + nom_sem + str(semtagid) + "\n" + # le détail du calcul tag par tag + # chaine += "Détail du calcul du tag\n" + # chaine += "-----------------------\n" + # for tag in semtag.taglist: + # chaine += "Tag=" + tag + "\n" + # chaine += semtag.str_detail_resultat_d_un_tag(tag, etudid=etudid) + "\n" + # le bilan des tags + chaine += "Bilan des tags\n" + chaine += "--------------\n" + for tag in semtag.taglist: + chaine += ( + tag + ";" + semtag.str_resTag_d_un_etudiant(tag, etudid) + "\n" + ) + chaine += "\n" + return chaine + + def get_date_entree_etudiant(self, etudid) -> str: + """Renvoie la date d'entree d'un étudiant: "1996" """ + annees_debut = [ + int(sem["annee_debut"]) for sem in self.ETUDINFO_DICT[etudid]["sems"] + ] + if annees_debut: + return str(min(annees_debut)) + return "" + + +# ---------------------------------------------------------------------------------------- +# Fonctions + + +# ---------------------------------------------------------------------------------------- +def get_annee_diplome_semestre(sem) -> int: + """Pour un semestre donne, décrit par le biais du dictionnaire sem usuel : + sem = {'formestre_id': ..., 'semestre_id': ..., 'annee_debut': ...}, + à condition qu'il soit un semestre de formation DUT, + predit l'annee à laquelle sera remis le diplome DUT des etudiants scolarisés dans le semestre + (en supposant qu'il n'y ait plus de redoublement) et la renvoie sous la forme d'un int. + Hypothese : les semestres de 1ere partie d'annee universitaire (comme des S1 ou des S3) s'etalent + sur deux annees civiles - contrairement au semestre de seconde partie d'annee universitaire (comme + des S2 ou des S4). + Par exemple : + > S4 debutant en 2016 finissant en 2016 => diplome en 2016 + > S3 debutant en 2015 et finissant en 2016 => diplome en 2016 + > S3 (decale) debutant en 2015 et finissant en 2015 => diplome en 2016 + La regle de calcul utilise l'annee_fin du semestre sur le principe suivant : + nbreSemRestant = nombre de semestres restant avant diplome + nbreAnneeRestant = nombre d'annees restant avant diplome + 1 - delta = 0 si semestre de 1ere partie d'annee / 1 sinon + decalage = active ou desactive un increment a prendre en compte en cas de semestre decale + """ + if ( + 1 <= sem["semestre_id"] <= 4 + ): # Si le semestre est un semestre DUT => problème si formation DUT en 1 an ?? + nbreSemRestant = 4 - sem["semestre_id"] + nbreAnRestant = nbreSemRestant // 2 + delta = int(sem["annee_fin"]) - int(sem["annee_debut"]) + decalage = nbreSemRestant % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1 + increment = decalage * (1 - delta) + return int(sem["annee_fin"]) + nbreAnRestant + increment + + +# ---------------------------------------------------------------------------------------- + + +# ---------------------------------------------------------------------------------- +def get_cosemestres_diplomants(semBase, avec_meme_formation=False): + """Partant d'un semestre de Base = {'formsemestre_id': ..., 'semestre_id': ..., 'annee_debut': ...}, + renvoie la liste de tous ses co-semestres (lui-meme inclus) + Par co-semestre, s'entend les semestres : + > dont l'annee predite pour la remise du diplome DUT est la meme + > dont la formation est la même (optionnel) + > ne prenant en compte que les etudiants sans redoublement + """ + tousLesSems = ( + sco_formsemestre.do_formsemestre_list() + ) # tous les semestres memorisés dans scodoc + diplome = get_annee_diplome_semestre(semBase) + + if avec_meme_formation: # si une formation est imposee + nom_formation = str(semBase["formation_id"]) + if pe_tools.PE_DEBUG: + pe_tools.pe_print(" - avec formation imposée : ", nom_formation) + coSems = [ + sem + for sem in tousLesSems + if get_annee_diplome_semestre(sem) == diplome + and sem["formation_id"] == semBase["formation_id"] + ] + else: + if pe_tools.PE_DEBUG: + pe_tools.pe_print(" - toutes formations confondues") + coSems = [ + sem for sem in tousLesSems if get_annee_diplome_semestre(sem) == diplome + ] + + return coSems diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index de061800..944566a0 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -685,6 +685,11 @@ class TF(object): '' % (field, values[field]) ) + elif input_type == "time": # JavaScript widget for date input + lem.append( + f"""""" + ) elif input_type == "text_suggest": lem.append( '\n' + f""" + + + """ ) if init_google_maps: # It may be necessary to add an API key: @@ -219,19 +226,26 @@ def sco_header( # jQuery H.append( - f""" - """ + f""" + + + """ ) # qTip if init_qtip: H.append( f""" - """ + + """ ) H.append( - f""" - """ + f""" + + + """ ) if init_google_maps: H.append( diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index 9806b115..b64cefc6 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -32,12 +32,68 @@ from flask import render_template, url_for from flask import g, request from flask_login import current_user +from app import db +from app.models import Evaluation, GroupDescr, ModuleImpl, Partition import app.scodoc.sco_utils as scu from app.scodoc import sco_preferences from app.scodoc.sco_permissions import Permission from sco_version import SCOVERSION +def retreive_formsemestre_from_request() -> int: + """Cherche si on a de quoi déduire le semestre affiché à partir des + arguments de la requête: + formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id + Returns None si pas défini. + """ + if request.method == "GET": + args = request.args + elif request.method == "POST": + args = request.form + else: + return None + formsemestre_id = None + # Search formsemestre + group_ids = args.get("group_ids", []) + if "formsemestre_id" in args: + formsemestre_id = args["formsemestre_id"] + elif "moduleimpl_id" in args and args["moduleimpl_id"]: + modimpl = db.session.get(ModuleImpl, args["moduleimpl_id"]) + if not modimpl: + return None # suppressed ? + formsemestre_id = modimpl.formsemestre_id + elif "evaluation_id" in args: + evaluation = db.session.get(Evaluation, args["evaluation_id"]) + if not evaluation: + return None # evaluation suppressed ? + formsemestre_id = evaluation.moduleimpl.formsemestre_id + elif "group_id" in args: + group = db.session.get(GroupDescr, args["group_id"]) + if not group: + return None + formsemestre_id = group.partition.formsemestre_id + elif group_ids: + if isinstance(group_ids, str): + group_ids = group_ids.split(",") + group_id = group_ids[0] + group = db.session.get(GroupDescr, group_id) + if not group: + return None + formsemestre_id = group.partition.formsemestre_id + elif "partition_id" in args: + partition = db.session.get(Partition, args["partition_id"]) + if not partition: + return None + formsemestre_id = partition.formsemestre_id + + if formsemestre_id is None: + return None # no current formsemestre + try: + return int(formsemestre_id) + except ValueError: + return None # no current formsemestre + + def sidebar_common(): "partie commune à toutes les sidebar" home_link = url_for("scodoc.index", scodoc_dept=g.scodoc_dept) @@ -107,7 +163,7 @@ def sidebar(etudid: int = None): etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] params.update(etud) params["fiche_url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ) # compte les absences du semestre en cours H.append( @@ -129,13 +185,17 @@ def sidebar(etudid: int = None): ) H.append("

    ") if current_user.has_permission(Permission.AbsChange): + # essaie de conserver le semestre actuellement en vue + cur_formsemestre_id = retreive_formsemestre_from_request() H.append( f"""
  • Ajouter
  • Justifier
  • """ ) diff --git a/app/scodoc/sco_abs_billets.py b/app/scodoc/sco_abs_billets.py index 18fe777f..4a9da0af 100644 --- a/app/scodoc/sco_abs_billets.py +++ b/app/scodoc/sco_abs_billets.py @@ -129,7 +129,7 @@ def table_billets( ] = f'id="{billet.etudiant.id}" class="etudinfo"' if with_links: billet_dict["_nomprenom_target"] = url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=billet_dict["etudid"], ) diff --git a/app/scodoc/sco_abs_notification.py b/app/scodoc/sco_abs_notification.py index b97f4418..50c50ae4 100644 --- a/app/scodoc/sco_abs_notification.py +++ b/app/scodoc/sco_abs_notification.py @@ -34,7 +34,7 @@ Il suffit d'appeler abs_notify() après chaque ajout d'absence. import datetime from typing import Optional -from flask import current_app, g, url_for +from flask import g, url_for from flask_mail import Message from app import db @@ -42,6 +42,7 @@ from app import email from app import log from app.auth.models import User from app.models.absences import AbsenceNotification +from app.models.etudiants import Identite from app.models.events import Scolog from app.models.formsemestre import FormSemestre import app.scodoc.notesdb as ndb @@ -108,7 +109,6 @@ def do_abs_notify( return # abort # Vérification fréquence (pour ne pas envoyer de mails trop souvent) - # TODO Mettre la fréquence dans les préférences assiduités abs_notify_max_freq = sco_preferences.get_preference("abs_notify_max_freq") destinations_filtered = [] for email_addr in destinations: @@ -175,9 +175,15 @@ def abs_notify_get_destinations( if prefs["abs_notify_email"]: destinations.append(prefs["abs_notify_email"]) if prefs["abs_notify_etud"]: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - if etud["email_default"]: - destinations.append(etud["email_default"]) + etud = Identite.get_etud(etudid) + adresse = etud.adresses.first() + if adresse: + # Mail à utiliser pour les envois vers l'étudiant: + # choix qui pourrait être controlé par une preference + # ici priorité au mail institutionnel: + email_default = adresse.email or adresse.emailperso + if email_default: + destinations.append(email_default) # Notification (à chaque fois) des resp. de modules ayant des évaluations # à cette date @@ -271,7 +277,7 @@ def abs_notification_message( values["nbabsjust"] = nbabsjust values["nbabsnonjust"] = nbabs - nbabsjust values["url_ficheetud"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True ) template = prefs["abs_notification_mail_tmpl"] diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 1dca9f02..6f174f15 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -62,7 +62,7 @@ def can_edit_etud_archive(authuser): def etud_list_archives_html(etud: Identite): - """HTML snippet listing archives""" + """HTML snippet listing archives.""" can_edit = can_edit_etud_archive(current_user) etud_archive_id = etud.id L = [] @@ -177,7 +177,7 @@ def etud_upload_file_form(etudid): return "\n".join(H) + tf[1] + html_sco_header.sco_footer() elif tf[0] == -1: return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) else: data = tf[2]["datafile"].read() @@ -188,7 +188,7 @@ def etud_upload_file_form(etudid): etud_archive_id, data, filename, description=descr ) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -228,7 +228,7 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False): ), dest_url="", cancel_url=url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, head_message="annulation", @@ -239,7 +239,7 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False): ETUDS_ARCHIVER.delete_archive(archive_id, dept_id=etud["dept_id"]) flash("Archive supprimée") return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py index b0fb1d3e..abdb9fff 100644 --- a/app/scodoc/sco_archives_justificatifs.py +++ b/app/scodoc/sco_archives_justificatifs.py @@ -17,7 +17,15 @@ from app import log class Trace: """gestionnaire de la trace des fichiers justificatifs - XXX TODO à documenter: rôle et format des fichier strace + + Role des fichiers traces : + - Sauvegarder la date de dépot du fichier + - Sauvegarder la date de suppression du fichier (dans le cas de plusieurs fichiers pour un même justif) + - Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier (=> permet de montrer les fichiers qu'aux personnes qui l'on déposé / qui ont le rôle AssiJustifView) + + _trace.csv : + nom_fichier_srv,datetime_depot,datetime_suppr,user_id + """ def __init__(self, path: str) -> None: @@ -39,7 +47,7 @@ class Trace: continue entry_date: datetime = is_iso_formated(csv[1], True) delete_date: datetime = is_iso_formated(csv[2], True) - user_id = csv[3] + user_id = csv[3].strip() self.content[fname] = [entry_date, delete_date, user_id] if os.path.isfile(self.path): @@ -84,7 +92,14 @@ class Trace: self, fnames: list[str] = None ) -> dict[str, list[datetime, datetime, str]]: """Récupère la trace pour les noms de fichiers. - si aucun nom n'est donné, récupère tous les fichiers""" + si aucun nom n'est donné, récupère tous les fichiers + + retour : + { + "nom_fichier_srv": [datetime_depot, datetime_suppr/None, user_id], + ... + } + """ if fnames is None: return self.content @@ -215,8 +230,7 @@ class JustificatifArchiver(BaseArchiver): filenames = self.list_archive(archive_id, dept_id=etud.dept_id) trace: Trace = Trace(archive_id) traced = trace.get_trace(filenames) - - return [(key, value[2]) for key, value in traced.items()] + return [(key, value[2]) for key, value in traced.items() if value is not None] def get_justificatif_file(self, archive_name: str, etud: Identite, filename: str): """ diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 5f8287e6..fd309866 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -6,7 +6,7 @@ from pytz import UTC from flask_sqlalchemy.query import Query -from app import log, db +from app import log, db, set_sco_dept import app.scodoc.sco_utils as scu from app.models.assiduites import Assiduite, Justificatif, compute_assiduites_justified from app.models.etudiants import Identite @@ -170,7 +170,7 @@ class CountCalculator: """Récupère une clé de dictionnaire en fonction de l'état de l'assiduité et si elle est justifié """ - keys: dict[EtatAssiduite, str] = { + keys: dict[scu.EtatAssiduite, str] = { scu.EtatAssiduite.ABSENT: "absent", scu.EtatAssiduite.RETARD: "retard", scu.EtatAssiduite.PRESENT: "present", @@ -349,6 +349,11 @@ def get_assiduites_stats( assiduites: Query, metric: str = "all", filtered: dict[str, object] = None ) -> dict[str, int | float]: """Compte les assiduités en fonction des filtres""" + # XXX TODO-assiduite : documenter !!! + # Que sont les filtres ? Quelles valeurs ? + # documenter permet de faire moins de bug: qualité du code non satisfaisante. + # + # + on se perd entre les clés en majuscules et en minuscules. Pourquoi if filtered is not None: deb, fin = None, None @@ -390,17 +395,18 @@ def get_assiduites_stats( # Récupération des états etats: list[str] = ( - filtered["etat"].split(",") - if "etat" in filtered - else ["absent", "present", "retard"] + filtered["etat"].split(",") if "etat" in filtered else scu.EtatAssiduite.all() ) # être sur que les états sont corrects - etats = [etat for etat in etats if etat in ["absent", "present", "retard"]] + etats = [etat for etat in etats if etat.upper() in scu.EtatAssiduite.all()] # Préparation du dictionnaire de retour avec les valeurs du calcul count: dict = calculator.to_dict(only_total=False) for etat in etats: + # TODO-assiduite: on se perd entre les lower et upper. + # Pourquoi EtatAssiduite est en majuscules si tout le reste est en minuscules ? + etat = etat.lower() if etat != "present": output[etat] = count[etat] output[etat]["justifie"] = count[etat + "_just"] @@ -452,8 +458,6 @@ def filter_by_date( if date_fin is None: date_fin = datetime.max - date_deb = scu.localize_datetime(date_deb) # TODO A modifier (timezone ?) - date_fin = scu.localize_datetime(date_fin) if not strict: return collection.filter( collection_cls.date_debut <= date_fin, collection_cls.date_fin >= date_deb @@ -560,15 +564,19 @@ def get_all_justified( return after -def create_absence( +def create_absence_billet( date_debut: datetime, date_fin: datetime, etudid: int, description: str = None, est_just: bool = False, ) -> int: - """TODO: doc, dire quand l'utiliser""" - # TODO + """ + Permet de rapidement créer une absence. + **UTILISÉ UNIQUEMENT POUR LES BILLETS** + Ne pas utiliser autre par. + TALK: Vérifier si nécessaire + """ etud: Identite = Identite.query.filter_by(etudid=etudid).first_or_404() assiduite_unique: Assiduite = Assiduite.create_assiduite( etud=etud, @@ -650,8 +658,7 @@ def get_assiduites_count_in_interval( """ date_debut_iso = date_debut_iso or date_debut.isoformat() date_fin_iso = date_fin_iso or date_fin.isoformat() - # TODO Question: pourquoi ne pas cacher toutes les métriques, si l'API les veut toutes ? - key = f"{etudid}_{date_debut_iso}_{date_fin_iso}{metrique}_assiduites" + key = f"{etudid}_{date_debut_iso}_{date_fin_iso}_assiduites" r = sco_cache.AbsSemEtudCache.get(key) if not r or moduleimpl_id is not None: @@ -668,26 +675,27 @@ def get_assiduites_count_in_interval( calculator: CountCalculator = CountCalculator() calculator.compute_assiduites(assiduites) calcul: dict = calculator.to_dict(only_total=False) - nb_abs: dict = calcul["absent"][metrique] - nb_abs_just: dict = calcul["absent_just"][metrique] - r = (nb_abs, nb_abs_just) + r = calcul if moduleimpl_id is None: ans = sco_cache.AbsSemEtudCache.set(key, r) if not ans: log("warning: get_assiduites_count failed to cache") - return r + + nb_abs: dict = r["absent"][metrique] + nb_abs_just: dict = r["absent_just"][metrique] + return (nb_abs, nb_abs_just) def invalidate_assiduites_count(etudid: int, sem: dict): """Invalidate (clear) cached counts""" date_debut = sem["date_debut_iso"] date_fin = sem["date_fin_iso"] - for met in scu.AssiduitesMetrics.TAG: - key = str(etudid) + "_" + date_debut + "_" + date_fin + f"{met}_assiduites" - sco_cache.AbsSemEtudCache.delete(key) + key = str(etudid) + "_" + date_debut + "_" + date_fin + "_assiduites" + sco_cache.AbsSemEtudCache.delete(key) +# Non utilisé def invalidate_assiduites_count_sem(sem: dict): """Invalidate (clear) cached abs counts for all the students of this semestre""" inscriptions = ( @@ -756,3 +764,14 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None): etudid = etudid if etudid is not None else obj["etudid"] invalidate_assiduites_etud_date(etudid, date_debut) invalidate_assiduites_etud_date(etudid, date_fin) + + # mettre à jour le scodoc_dept en fonction de l'étudiant + etud = Identite.query.filter_by(etudid=etudid).first_or_404() + set_sco_dept(etud.departement.acronym) + + # Invalide les caches des tableaux de l'étudiant + sco_cache.RequeteTableauAssiduiteCache.delete_pattern( + pattern=f"tableau-etud-{etudid}*" + ) + # Invalide les tableaux "bilan dept" + sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern=f"tableau-dept*") diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py index f61ba8ac..adac2988 100644 --- a/app/scodoc/sco_bulletins_pdf.py +++ b/app/scodoc/sco_bulletins_pdf.py @@ -60,6 +60,7 @@ import traceback from flask import g, request from app import log, ScoValueError +from app.comp.res_but import ResultatsSemestreBUT from app.models import FormSemestre, Identite from app.scodoc import sco_cache from app.scodoc import codes_cursus @@ -318,14 +319,34 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"): return pdfdoc, filename -def get_filigranne(etud_etat: str, prefs, decision_sem=None) -> str: - """Texte à placer en "filigranne" sur le bulletin pdf""" +def get_filigranne( + etud_etat: str, prefs, decision_sem: str | None | bool = None +) -> str: + """Texte à placer en "filigranne" sur le bulletin pdf. + etud_etat : etat de l'inscription (I ou D) + decision_sem = code jury ou vide + """ if etud_etat == scu.DEMISSION: return "Démission" - elif etud_etat == codes_cursus.DEF: + if etud_etat == codes_cursus.DEF: return "Défaillant" - elif (prefs["bul_show_temporary"] and not decision_sem) or prefs[ + if (prefs["bul_show_temporary"] and not decision_sem) or prefs[ "bul_show_temporary_forced" ]: return prefs["bul_temporary_txt"] return "" + + +def get_filigranne_apc( + etud_etat: str, prefs, etudid: int, res: ResultatsSemestreBUT +) -> str: + """Texte à placer en "filigranne" sur le bulletin pdf. + Version optimisée pour BUT + """ + if prefs["bul_show_temporary_forced"]: + return get_filigranne(etud_etat, prefs) + if prefs["bul_show_temporary"]: + # requete les décisions de jury + decision_sem = res.etud_has_decision(etudid) + return get_filigranne(etud_etat, prefs, decision_sem=decision_sem) + return get_filigranne(etud_etat, prefs) diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index e31b6a18..e72ee1bd 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -396,3 +396,13 @@ class ValidationsSemestreCache(ScoDocCache): prefix = "VSC" timeout = 60 * 60 # ttl 1 heure (en phase de mise au point) + + +class RequeteTableauAssiduiteCache(ScoDocCache): + """ + clé : "::::::" + Valeur = liste de dicts + """ + + prefix = "TABASSI" + timeout = 60 * 60 # Une heure diff --git a/app/scodoc/sco_cursus_dut.py b/app/scodoc/sco_cursus_dut.py index b9e26e32..47e7a5fe 100644 --- a/app/scodoc/sco_cursus_dut.py +++ b/app/scodoc/sco_cursus_dut.py @@ -39,7 +39,6 @@ from app import log from app.scodoc.scolog import logdb from app.scodoc import sco_cache, sco_etud from app.scodoc import sco_formsemestre -from app.scodoc import sco_formations from app.scodoc.codes_cursus import ( CMP, ADC, diff --git a/app/scodoc/sco_debouche.py b/app/scodoc/sco_debouche.py index cc08e8d9..f027b553 100644 --- a/app/scodoc/sco_debouche.py +++ b/app/scodoc/sco_debouche.py @@ -134,10 +134,10 @@ def table_debouche_etudids(etudids, keep_numeric=True): "nom": etud["nom"], "prenom": etud["prenom"], "_nom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), "_prenom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), "_nom_td_attrs": 'id="%s" class="etudinfo"' % (etud["etudid"]), # 'debouche' : etud['debouche'], diff --git a/app/scodoc/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py index d4b8a1aa..b8cdf9ac 100644 --- a/app/scodoc/sco_edt_cal.py +++ b/app/scodoc/sco_edt_cal.py @@ -177,12 +177,26 @@ def formsemestre_edt_dict( 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) except ScoValueError as exc: return exc.args[0] - # Génération des événements pour le calendrier html + edt_dict = translate_calendar( + events_scodoc, group_ids, show_modules_titles=show_modules_titles + ) + log( + f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s" + ) + return edt_dict + + +def translate_calendar( + events_scodoc: list[dict], + group_ids: list[int] = None, + show_modules_titles=True, +) -> list[dict]: + """Génération des événements pour le calendrier html""" + group_ids_set = set(group_ids) if group_ids else set() promo_icon = f"""promotion""" abs_icon = f""" re.Pattern: def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[str]]: - """Chargement fichier ics, filtrage et extraction des identifiants. + """Chargement fichier ics. + Renvoie une liste d'évènements, et la liste des identifiants de groupes + trouvés (utilisée pour l'aide). + """ + # Chargement du calendier ics + _, calendar = formsemestre_load_calendar(formsemestre) + if not calendar: + return [], [] + # --- Correspondances id edt -> id scodoc pour groupes + edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) + default_group = formsemestre.get_default_group() + # --- Correspondances id edt -> id scodoc pour modimpls + edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) + return convert_ics( + calendar, + edt2group=edt2group, + default_group=default_group, + edt2modimpl=edt2modimpl, + ) + + +def convert_ics( + calendar: icalendar.cal.Calendar, + edt2group: dict[str, GroupDescr] = None, + default_group: GroupDescr = None, + edt2modimpl: dict[str, ModuleImpl] = None, +) -> tuple[list[dict], list[str]]: + """Filtrage et extraction des identifiants des évènements calendrier. + Renvoie une liste d'évènements, et la liste des identifiants de groupes trouvés (utilisée pour l'aide). @@ -310,10 +351,6 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s - False si extraction regexp non configuré - "tous" (promo) si pas de correspondance trouvée. """ - # 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") @@ -348,15 +385,13 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field") edt_ics_uid_pattern = get_ics_uid_pattern() - # --- Correspondances id edt -> id scodoc pour groupes, modules et enseignants - edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre) + # --- Groupes group_colors = { group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1] for i, group_name in enumerate(edt2group) } edt_groups_ids = set() # les ids de groupes normalisés tels que dans l'ics - default_group = formsemestre.get_default_group() - edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre) + edt2user: dict[str, User | None] = {} # construit au fur et à mesure (cache) # --- events = [e for e in calendar.walk() if e.name == "VEVENT"] @@ -371,29 +406,6 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s ) # title remplacé par le nom du module scodoc quand il est trouvé title = title_edt - # --- Group - if edt_ics_group_pattern: - edt_group = extract_event_edt_id( - event, edt_ics_group_field, edt_ics_group_pattern - ) - edt_groups_ids.add(edt_group) - # si pas de groupe dans l'event, ou 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 if edt_ics_mod_pattern: edt_module = extract_event_edt_id( @@ -405,6 +417,34 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s else: modimpl = False edt_module = "" + # --- Group + if edt_ics_group_pattern: + edt_group = extract_event_edt_id( + event, edt_ics_group_field, edt_ics_group_pattern + ) + edt_groups_ids.add(edt_group) + # si pas de groupe dans l'event, ou si groupe non reconnu, + # prend toute la promo ("tous") + event_default_group = ( + default_group + if default_group + else (modimpl.formsemestre.get_default_group() if modimpl else None) + ) + group: GroupDescr = ( + edt2group.get(edt_group, event_default_group) + if edt_group + else event_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 + # --- Enseignants users: list[User] = [] if edt_ics_uid_pattern: @@ -446,6 +486,8 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s "jour": event.decoded("dtstart").date().isoformat(), "start": event.decoded("dtstart").isoformat(), "end": event.decoded("dtend").isoformat(), + "UID": event.decoded("UID").decode("utf-8"), + "raw": event.to_ical().decode("utf-8"), } ) return events_sco, sorted(edt_groups_ids) diff --git a/app/scodoc/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py index 6247d986..f680775c 100644 --- a/app/scodoc/sco_etape_apogee_view.py +++ b/app/scodoc/sco_etape_apogee_view.py @@ -542,7 +542,9 @@ def view_scodoc_etuds(semset_id, title="", nip_list="", fmt="html"): etuds = [sco_etud.get_etud_info(code_nip=nip, filled=True)[0] for nip in nips] for e in etuds: - tgt = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"]) + tgt = url_for( + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"] + ) e["_nom_target"] = tgt e["_prenom_target"] = tgt e["_nom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """ @@ -769,10 +771,10 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"): e["in_scodoc"] = e["nip"] not in nips_no_sco e["in_scodoc_str"] = {True: "oui", False: "non"}[e["in_scodoc"]] if e["in_scodoc"]: - e["_in_scodoc_str_target"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, code_nip=e["nip"] - ) e.update(sco_etud.get_etud_info(code_nip=e["nip"], filled=True)[0]) + e["_in_scodoc_str_target"] = url_for( + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"] + ) e["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"],) e["_prenom_td_attrs"] = 'id="pre-%s" class="etudinfo"' % (e["etudid"],) else: diff --git a/app/scodoc/sco_etape_bilan.py b/app/scodoc/sco_etape_bilan.py index 575d2596..86da7166 100644 --- a/app/scodoc/sco_etape_bilan.py +++ b/app/scodoc/sco_etape_bilan.py @@ -692,7 +692,7 @@ class EtapeBilan: @staticmethod def link_etu(etudid, nom): return '%s' % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), nom, ) diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index e7041021..f6b8a18d 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -64,7 +64,7 @@ def format_etud_ident(etud: dict): Note: par rapport à Identite.to_dict_bul(), ajoute les champs: - 'email_default', 'nom_disp', 'nom_usuel', 'civilite_etat_civil_str', 'ne', 'civilite_str' + 'nom_disp', 'nom_usuel', 'civilite_etat_civil_str', 'ne', 'civilite_str' """ etud["nom"] = format_nom(etud["nom"]) if "nom_usuel" in etud: @@ -98,10 +98,6 @@ def format_etud_ident(etud: dict): etud["ne"] = "e" else: # 'X' etud["ne"] = "(e)" - # Mail à utiliser pour les envois vers l'étudiant: - # choix qui pourrait être controé par une preference - # ici priorité au mail institutionnel: - etud["email_default"] = etud.get("email", "") or etud.get("emailperso", "") def force_uppercase(s): @@ -117,36 +113,6 @@ def _format_etat_civil(etud: dict) -> str: return etud["nomprenom"] -def format_lycee(nomlycee): - nomlycee = nomlycee.strip() - s = nomlycee.lower() - if s[:5] == "lycee" or s[:5] == "lycée": - return nomlycee[5:] - else: - return nomlycee - - -def format_telephone(n): - if n is None: - return "" - if len(n) < 7: - return n - else: - n = n.replace(" ", "").replace(".", "") - i = 0 - r = "" - j = len(n) - 1 - while j >= 0: - r = n[j] + r - if i % 2 == 1 and j != 0: - r = " " + r - i += 1 - j -= 1 - if len(r) == 13 and r[0] != "0": - r = "0" + r - return r - - def format_pays(s): "laisse le pays seulement si != FRANCE" if s.upper() != "FRANCE": @@ -283,14 +249,14 @@ def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True) listh.append( f"""Autre étudiant: {e['nom']} {e['prenom']}""" ) if etudid: OK = "retour à la fiche étudiant" - dest_endpoint = "scolar.ficheEtud" + dest_endpoint = "scolar.fiche_etud" parameters = {"etudid": etudid} else: if "tf_submitted" in args: @@ -619,7 +585,7 @@ def create_etud(cnx, args: dict = None): etud_dict = etudident_list(cnx, {"etudid": etudid})[0] fill_etuds_info([etud_dict]) etud_dict["url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ) ScolarNews.add( typ=ScolarNews.NEWS_INSCR, @@ -724,19 +690,28 @@ def get_etablissements(): def get_lycee_infos(codelycee): - E = get_etablissements() - return E.get(codelycee, None) + etablissements = get_etablissements() + return etablissements.get(codelycee, None) -def format_lycee_from_code(codelycee): +def format_lycee_from_code(codelycee: str) -> str: "Description lycee à partir du code" - E = get_etablissements() - if codelycee in E: - e = E[codelycee] + etablissements = get_etablissements() + if codelycee in etablissements: + e = etablissements[codelycee] nomlycee = e["name"] - return "%s (%s)" % (nomlycee, e["commune"]) + return f"{nomlycee} ({e['commune']})" + return f"{codelycee} (établissement inconnu)" + + +def format_lycee(nomlycee: str) -> str: + "mise en forme nom de lycée" + nomlycee = nomlycee.strip() + s = nomlycee.lower() + if s[:5] == "lycee" or s[:5] == "lycée": + return nomlycee[5:] else: - return "%s (établissement inconnu)" % codelycee + return nomlycee def etud_add_lycee_infos(etud): @@ -821,36 +796,6 @@ def fill_etuds_info(etuds: list[dict], add_admission=True): # nettoyage champs souvent vides etud["codepostallycee"] = etud.get("codepostallycee", "") or "" etud["nomlycee"] = etud.get("nomlycee", "") or "" - if etud.get("nomlycee"): - etud["ilycee"] = "Lycée " + format_lycee(etud["nomlycee"]) - if etud["villelycee"]: - etud["ilycee"] += " (%s)" % etud.get("villelycee", "") - etud["ilycee"] += "
    " - else: - if etud.get("codelycee"): - etud["ilycee"] = format_lycee_from_code(etud["codelycee"]) - else: - etud["ilycee"] = "" - rap = "" - if etud.get("rapporteur") or etud.get("commentaire"): - rap = "Note du rapporteur" - if etud.get("rapporteur"): - rap += " (%s)" % etud["rapporteur"] - rap += ": " - if etud.get("commentaire"): - rap += "%s" % etud["commentaire"] - etud["rap"] = rap - - if etud.get("telephone"): - etud["telephonestr"] = "Tél.: " + format_telephone(etud["telephone"]) - else: - etud["telephonestr"] = "" - if etud.get("telephonemobile"): - etud["telephonemobilestr"] = "Mobile: " + format_telephone( - etud["telephonemobile"] - ) - else: - etud["telephonemobilestr"] = "" def etud_inscriptions_infos(etudid: int, ne="") -> dict: diff --git a/app/scodoc/sco_evaluation_check_abs.py b/app/scodoc/sco_evaluation_check_abs.py index bd6b8ded..eb35312d 100644 --- a/app/scodoc/sco_evaluation_check_abs.py +++ b/app/scodoc/sco_evaluation_check_abs.py @@ -156,7 +156,7 @@ def evaluation_check_absences_html( H.append( f"""
  • {etud.nomprenom}""" ) diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index 9e0c487f..9b5c716f 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -178,9 +178,7 @@ def evaluation_create_form( { "title": "Heure de début", "explanation": "heure du début de l'épreuve", - "input_type": "menu", - "allowed_values": heures, - "labels": heures, + "input_type": "time", }, ), ( @@ -188,9 +186,7 @@ def evaluation_create_form( { "title": "Heure de fin", "explanation": "heure de fin de l'épreuve", - "input_type": "menu", - "allowed_values": heures, - "labels": heures, + "input_type": "time", }, ), ] @@ -335,6 +331,7 @@ def evaluation_create_form( + "\n" + tf[1] + render_template("scodoc/help/evaluations.j2", is_apc=is_apc) + + render_template("sco_timepicker.j2") + html_sco_header.sco_footer() ) elif tf[0] == -1: diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index 22aa43eb..382f883d 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -691,7 +691,7 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True) group_ids=group_id, evaluation_id=evaluation.id, date_debut=evaluation.date_debut.isoformat(), - date_fin=evaluation.date_fin.isoformat(), + date_fin=evaluation.date_fin.isoformat() if evaluation.date_fin else "", ) }">absences ce jour diff --git a/app/scodoc/sco_export_results.py b/app/scodoc/sco_export_results.py index d9a89b2b..beaf7378 100644 --- a/app/scodoc/sco_export_results.py +++ b/app/scodoc/sco_export_results.py @@ -173,9 +173,10 @@ def _build_results_list(dpv_by_sem, etuds_infos): "nom_usuel": etud["nom_usuel"], "prenom": etud["prenom"], "civilite_str": etud["civilite_str"], - "_nom_target": "%s" - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - "_nom_td_attrs": 'id="%s" class="etudinfo"' % etudid, + "_nom_target": url_for( + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + "_nom_td_attrs": f'id="{etudid}" class="etudinfo"', "bac": bac.abbrev(), "parcours": dec["parcours"], } diff --git a/app/scodoc/sco_find_etud.py b/app/scodoc/sco_find_etud.py index 579992da..d86f4dee 100644 --- a/app/scodoc/sco_find_etud.py +++ b/app/scodoc/sco_find_etud.py @@ -145,7 +145,7 @@ def search_etud_in_dept(expnom=""): if "dest_url" in vals: endpoint = vals["dest_url"] else: - endpoint = "scolar.ficheEtud" + endpoint = "scolar.fiche_etud" if "parameters_keys" in vals: for key in vals["parameters_keys"].split(","): url_args[key] = vals[key] @@ -328,8 +328,9 @@ def table_etud_in_accessible_depts(expnom=None): """ result, accessible_depts = search_etud_in_accessible_depts(expnom=expnom) H = [ - """
    """, - """

    Recherche multi-département de "%s"

    """ % expnom, + f"""
    +

    Recherche multi-département de "{expnom}"

    + """, ] for etuds in result: if etuds: @@ -337,9 +338,9 @@ def table_etud_in_accessible_depts(expnom=None): # H.append('

    Département %s

    ' % DeptId) for e in etuds: e["_nomprenom_target"] = url_for( - "scolar.ficheEtud", scodoc_dept=dept_id, etudid=e["etudid"] + "scolar.fiche_etud", scodoc_dept=dept_id, etudid=e["etudid"] ) - e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) + e["_nomprenom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """ tab = GenTable( titles={"nomprenom": "Étudiants en " + dept_id}, diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index 22b600af..7c2cc31b 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -102,7 +102,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id): scodoc_dept=g.scodoc_dept, etudid=etudid, only_ext=1) }"> inscrire à un autre semestre"

    -

    Étudiant {etud.nomprenom}

    """, @@ -221,7 +221,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id): tf[2]["formation_id"] = orig_sem["formation_id"] formsemestre_ext_create(etudid, tf[2]) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index b1d73150..11c6b07d 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -400,7 +400,7 @@ def formsemestre_inscription_with_modules_form(etudid, only_ext=False): H.append("

    aucune session de formation !

    ") H.append( f"""

    ou

    retour à la fiche de {etud.nomprenom}""" ) return "\n".join(H) + footer @@ -440,7 +440,7 @@ def formsemestre_inscription_with_modules( dans le semestre {formsemestre.titre_mois()}

      -
    • retour à la fiche de {etud.nomprenom}
    • Aucune modification à effectuer

      retour à la fiche étudiant

      """ - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + % url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) return "\n".join(H) + footer @@ -755,7 +755,7 @@ function chkbx_select(field_id, state) { etudid, modulesimpls_ainscrire, modulesimpls_adesinscrire, - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), ) ) return "\n".join(H) + footer @@ -820,7 +820,7 @@ def do_moduleimpl_incription_options(

      Retour à la fiche étudiant

      """ - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + % url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), html_sco_header.sco_footer(), ] return "\n".join(H) @@ -885,7 +885,7 @@ def formsemestre_inscrits_ailleurs(formsemestre_id): '
    • %s : ' % ( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"], ), diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 2b89c1f7..20d3ec65 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -51,13 +51,14 @@ from app.models import ( NotesNotes, ) from app.scodoc.codes_cursus import UE_SPORT -import app.scodoc.sco_utils as scu -from app.scodoc.sco_utils import ModuleType -from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( ScoValueError, ScoInvalidIdType, ) +from app.scodoc.sco_permissions import Permission +import app.scodoc.sco_utils as scu +from app.scodoc.sco_utils import ModuleType + from app.scodoc import html_sco_header from app.scodoc import htmlutils from app.scodoc import sco_archives_formsemestre @@ -75,6 +76,7 @@ from app.scodoc import sco_moduleimpl from app.scodoc import sco_preferences from app.scodoc import sco_users from app.scodoc.gen_tables import GenTable +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_formsemestre_custommenu import formsemestre_custommenu_html import sco_version @@ -109,7 +111,7 @@ def _build_menu_stats(formsemestre_id): "title": "Lycées d'origine", "endpoint": "notes.formsemestre_etuds_lycees", "args": {"formsemestre_id": formsemestre_id}, - "enabled": True, + "enabled": current_user.has_permission(Permission.ViewEtudData), }, { "title": 'Table "poursuite"', @@ -336,6 +338,7 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: formsemestre_id, fix_if_missing=True ), }, + "enabled": current_user.has_permission(Permission.ViewEtudData), }, { "title": "Vérifier inscriptions multiples", @@ -474,57 +477,6 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: return "\n".join(H) -def retreive_formsemestre_from_request() -> int: - """Cherche si on a de quoi déduire le semestre affiché à partir des - arguments de la requête: - formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id - Returns None si pas défini. - """ - if request.method == "GET": - args = request.args - elif request.method == "POST": - args = request.form - else: - return None - formsemestre_id = None - # Search formsemestre - group_ids = args.get("group_ids", []) - if "formsemestre_id" in args: - formsemestre_id = args["formsemestre_id"] - elif "moduleimpl_id" in args and args["moduleimpl_id"]: - modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=args["moduleimpl_id"]) - if not modimpl: - return None # suppressed ? - modimpl = modimpl[0] - formsemestre_id = modimpl["formsemestre_id"] - elif "evaluation_id" in args: - E = sco_evaluation_db.get_evaluations_dict( - {"evaluation_id": args["evaluation_id"]} - ) - if not E: - return None # evaluation suppressed ? - E = E[0] - modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - formsemestre_id = modimpl["formsemestre_id"] - elif "group_id" in args: - group = sco_groups.get_group(args["group_id"]) - formsemestre_id = group["formsemestre_id"] - elif group_ids: - if isinstance(group_ids, str): - group_ids = group_ids.split(",") - group_id = group_ids[0] - group = sco_groups.get_group(group_id) - formsemestre_id = group["formsemestre_id"] - elif "partition_id" in args: - partition = sco_groups.get_partition(args["partition_id"]) - formsemestre_id = partition["formsemestre_id"] - - if not formsemestre_id: - return None # no current formsemestre - - return int(formsemestre_id) - - # Element HTML decrivant un semestre (barre de menu et infos) def formsemestre_page_title(formsemestre_id=None): """Element HTML decrivant un semestre (barre de menu et infos) @@ -917,7 +869,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str: @@ -1457,7 +1409,7 @@ def formsemestre_warning_etuds_sans_note( noms = ", ".join( [ f"""{etud.nomprenom}""" for etud in etuds ] @@ -1519,13 +1471,13 @@ def formsemestre_note_etuds_sans_notes( a déjà des notes""" ) return redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) else: noms = "
    • ".join( [ f"""{etud.nomprenom}""" for etud in etuds ] diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index d11b334c..c8c95562 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -117,8 +117,8 @@ def formsemestre_validation_etud_form( if read_only: check = True - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) + etud_d = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + Se = sco_cursus.get_situation_etud_cursus(etud_d, formsemestre_id) if not Se.sem["etat"]: raise ScoValueError("validation: semestre verrouille") @@ -132,7 +132,7 @@ def formsemestre_validation_etud_form( H = [ html_sco_header.sco_header( - page_title=f"Parcours {etud['nomprenom']}", + page_title=f"Parcours {etud.nomprenom}", javascripts=["js/recap_parcours.js"], ) ] @@ -177,26 +177,22 @@ def formsemestre_validation_etud_form( H.append('
      ') if not check: H.append( - '

      %s: validation %s%s

      Parcours: %s' - % ( - etud["nomprenom"], - Se.parcours.SESSION_NAME_A, - Se.parcours.SESSION_NAME, - Se.get_cursus_descr(), - ) + f"""

      {etud.nomprenom}: validation { + Se.parcours.SESSION_NAME_A}{Se.parcours.SESSION_NAME + }

      Parcours: {Se.get_cursus_descr()} + """ ) else: H.append( - '

      Parcours de %s

      %s' - % (etud["nomprenom"], Se.get_cursus_descr()) + f"""

      Parcours de {etud.nomprenom}

      {Se.get_cursus_descr()}""" ) H.append( - '
      %s
      ' - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]), - ) + f"""{etud.photo_html(title="fiche de " + etud.nomprenom)} + + """ ) etud_etat = nt.get_etud_etat(etudid) @@ -210,7 +206,7 @@ def formsemestre_validation_etud_form(
      Impossible de statuer sur cet étudiant: il est démissionnaire ou défaillant (voir sa fiche)
      """ @@ -289,7 +285,7 @@ def formsemestre_validation_etud_form( etudid=etudid, origin_formsemestre_id=formsemestre_id ).all() if autorisations: - H.append(". Autorisé%s à s'inscrire en " % etud["ne"]) + H.append(f". Autorisé{etud.e} à s'inscrire en ") H.append(", ".join([f"S{aut.semestre_id}" for aut in autorisations]) + ".") H.append("

      ") diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index aacbae64..a404c7fa 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -52,7 +52,7 @@ from app.scodoc import sco_preferences from app.scodoc import sco_etud from app.scodoc.sco_etud import etud_sort_key from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_exceptions import ScoValueError, ScoPermissionDenied from app.scodoc.sco_permissions import Permission JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ @@ -118,6 +118,16 @@ def groups_view( init_qtip=True, ) } +
      {form_groups_choice(groups_infos, submit_on_change=True)} @@ -474,15 +484,12 @@ def groups_table( """ from app.scodoc import sco_report - # log( - # "enter groups_table %s: %s" - # % (groups_infos.members[0]["nom"], groups_infos.members[0].get("etape", "-")) - # ) + can_view_etud_data = int(current_user.has_permission(Permission.ViewEtudData)) with_codes = int(with_codes) - with_paiement = int(with_paiement) - with_archives = int(with_archives) - with_annotations = int(with_annotations) - with_bourse = int(with_bourse) + with_paiement = int(with_paiement) and can_view_etud_data + with_archives = int(with_archives) and can_view_etud_data + with_annotations = int(with_annotations) and can_view_etud_data + with_bourse = int(with_bourse) and can_view_etud_data base_url_np = groups_infos.base_url + f"&with_codes={with_codes}" base_url = ( @@ -527,7 +534,8 @@ def groups_table( if fmt != "html": # ne mentionne l'état que en Excel (style en html) columns_ids.append("etat") columns_ids.append("email") - columns_ids.append("emailperso") + if can_view_etud_data: + columns_ids.append("emailperso") if fmt == "moodlecsv": columns_ids = ["email", "semestre_groupe"] @@ -561,7 +569,7 @@ def groups_table( else: etud["_emailperso_target"] = "" fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_disp_target"] = fiche_url etud["_nom_disp_order"] = etud_sort_key(etud) @@ -616,7 +624,7 @@ def groups_table( + "+".join(sorted(moodle_groupenames)) ) else: - filename = "etudiants_%s" % groups_infos.groups_filename + filename = f"etudiants_{groups_infos.groups_filename}" prefs = sco_preferences.SemPreferences(groups_infos.formsemestre_id) tab = GenTable( @@ -664,28 +672,33 @@ def groups_table( """ ] if groups_infos.members: - Of = [] + menu_options = [] options = { - "with_paiement": "Paiement inscription", - "with_archives": "Fichiers archivés", - "with_annotations": "Annotations", - "with_codes": "Codes", - "with_bourse": "Statut boursier", + "with_codes": "Affiche codes", } - for option in options: + if can_view_etud_data: + options.update( + { + "with_paiement": "Paiement inscription", + "with_archives": "Fichiers archivés", + "with_annotations": "Annotations", + "with_bourse": "Statut boursier", + } + ) + for option, label in options.items(): if locals().get(option, False): selected = "selected" else: selected = "" - Of.append( - """""" - % (option, selected, options[option]) + menu_options.append( + f"""""" ) H.extend( [ - """""", + "\n".join(menu_options), """ """, + """accès aux données personnelles interdit""" + if not can_view_etud_data + else "", ] ) H.append("
      ") @@ -708,41 +724,45 @@ def groups_table( H.extend( [ tab.html(), - "", ] ) @@ -899,14 +926,19 @@ def tab_absences_html(groups_infos, etat=None): """ ) # Lien pour ajout fichiers étudiants - if authuser.has_permission(Permission.EtudAddAnnotations): + text = "Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)" + if authuser.has_permission( + Permission.EtudAddAnnotations + ) and authuser.has_permission(Permission.ViewEtudData): H.append( f"""
    • Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)
    • """ + )}">{text}""" ) + else: + H.append(f"""
    • {text}
    • """) H.append("
    ") return "".join(H) diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index 8a9ef08f..f97e0d44 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -669,7 +669,7 @@ def etuds_select_boxes( elink = """%s""" % ( c, url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"], ), diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 3bf7c0b6..62ed203f 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -29,6 +29,7 @@ """ from collections import defaultdict import numpy as np +import pandas as pd import flask from flask import url_for, g, request @@ -264,7 +265,7 @@ def _make_table_notes( if e.moduleimpl_id != modimpl.id: raise ValueError("invalid evaluations list") - if fmt == "xls": + if fmt == "xls" or fmt == "json": keep_numeric = True # pas de conversion des notes en strings else: keep_numeric = False @@ -279,11 +280,12 @@ def _make_table_notes( if anonymous_listing: columns_ids = ["code"] # cols in table else: - if fmt == "xls" or fmt == "xml": - columns_ids = ["nom", "prenom"] + if fmt in {"xls", "xml", "json"}: + columns_ids = ["etudid", "nom", "prenom"] else: columns_ids = ["nomprenom"] - if not hide_groups: + if not hide_groups and fmt not in {"xls", "xml", "json"}: + # n'indique pas les groupes en xls, json car notation "humaine" ici columns_ids.append("group") titles = { @@ -476,7 +478,7 @@ def _make_table_notes( if with_emails: columns_ids += ["email", "emailperso"] # Ajoute lignes en tête et moyennes - if len(evaluations) > 0 and fmt != "bordereau": + if len(evaluations) > 0 and fmt != "bordereau" and fmt != "json": rows_head = [row_coefs] if is_apc: rows_head.append(row_poids) @@ -683,7 +685,7 @@ def _make_table_notes( def _add_eval_columns( evaluation: Evaluation, eval_state, - evals_poids, + evals_poids: pd.DataFrame | None, ues, rows, titles, @@ -833,14 +835,22 @@ def _add_eval_columns( return notes, nb_abs, nb_att # pour histogramme -def _mini_table_eval_ue_poids(evaluation_id, evals_poids, ues): +def _mini_table_eval_ue_poids( + evaluation_id: int, evals_poids: pd.DataFrame, ues +) -> str: "contenu de la cellule: poids" + ue_poids = [ + (ue.acronyme, evals_poids[ue.id][evaluation_id]) + for ue in ues + if (evals_poids[ue.id][evaluation_id] or 0) > 0 + ] + return ( """" + "
    """ - + "".join([f"{ue.acronyme}" for ue in ues]) + + "".join([f"{up[0]}" for up in ue_poids]) + "
    " - + "".join([f"{evals_poids[ue.id][evaluation_id]}" for ue in ues]) + + "".join([f"{up[1]}" for up in ue_poids]) + "
    " ) diff --git a/app/scodoc/sco_lycee.py b/app/scodoc/sco_lycee.py index 606fabf2..da274dc9 100644 --- a/app/scodoc/sco_lycee.py +++ b/app/scodoc/sco_lycee.py @@ -143,7 +143,9 @@ def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False) if not no_links: for etud in etuds: fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", + scodoc_dept=g.scodoc_dept, + etudid=etud["etudid"], ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url @@ -232,7 +234,7 @@ def js_coords_lycees(etuds_by_lycee): '%s' % ( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"], ), diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 34b3d3bd..e12ff28d 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -40,6 +40,7 @@ from app.comp.res_compat import NotesTableCompat from app.models import ( FormSemestre, Identite, + ModuleImpl, Partition, ScolarFormSemestreValidation, UniteEns, @@ -52,7 +53,6 @@ from app.scodoc import codes_cursus from app.scodoc import sco_edit_module from app.scodoc import sco_edit_ue from app.scodoc import sco_etud -from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl @@ -63,7 +63,9 @@ import app.scodoc.sco_utils as scu from app.tables import list_etuds -def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): +def moduleimpl_inscriptions_edit( + moduleimpl_id, etudids: list[int] | None = None, submitted=False +): """Formulaire inscription des etudiants a ce module * Gestion des inscriptions Nom TD TA TP (triable) @@ -75,12 +77,12 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): * Si pas les droits: idem en readonly """ - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] - formsemestre_id = M["formsemestre_id"] - mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - sem = sco_formsemestre.get_formsemestre(formsemestre_id) + etudids = etudids or [] + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) + module = modimpl.module + formsemestre = modimpl.formsemestre # -- check lock - if not sem["etat"]: + if not formsemestre.etat: raise ScoValueError("opération impossible: semestre verrouille") header = html_sco_header.sco_header( page_title="Inscription au module", @@ -90,25 +92,23 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): footer = html_sco_header.sco_footer() H = [ header, - """

    Inscriptions au module %s (%s)

    + f"""

    Inscriptions au module {module.titre or "(module sans titre)"} ({module.code})

    Cette page permet d'éditer les étudiants inscrits à ce module (ils doivent évidemment être inscrits au semestre). - Les étudiants cochés sont (ou seront) inscrits. Vous pouvez facilement inscrire ou + Les étudiants cochés sont (ou seront) inscrits. Vous pouvez inscrire ou désinscrire tous les étudiants d'un groupe à l'aide des menus "Ajouter" et "Enlever".

    -

    Aucune modification n'est prise en compte tant que l'on n'appuie pas sur le bouton - "Appliquer les modifications". +

    Aucune modification n'est prise en compte tant que l'on n'appuie pas + sur le bouton "Appliquer les modifications".

    - """ - % ( - moduleimpl_id, - mod["titre"] or "(module sans titre)", - mod["code"] or "(module sans code)", - ), + """, ] # Liste des inscrits à ce semestre inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( - formsemestre_id + formsemestre.id ) for ins in inscrits: etuds_info = sco_etud.get_etud_info(etudid=ins["etudid"], filled=1) @@ -121,12 +121,10 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): ) ins["etud"] = etuds_info[0] inscrits.sort(key=lambda inscr: sco_etud.etud_sort_key(inscr["etud"])) - in_m = sco_moduleimpl.do_moduleimpl_inscription_list( - moduleimpl_id=M["moduleimpl_id"] - ) - in_module = set([x["etudid"] for x in in_m]) + in_m = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=modimpl.id) + in_module = {x["etudid"] for x in in_m} # - partitions = sco_groups.get_partitions_list(formsemestre_id) + partitions = sco_groups.get_partitions_list(formsemestre.id) # if not submitted: H.append( @@ -149,27 +147,32 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): } } - """ + + + """ ) H.append( f"""
    - + -

    - - { _make_menu(partitions, "Ajouter", "true") } - { _make_menu(partitions, "Enlever", "false")} -
    -


    - +
    + { _make_menu(partitions, "Ajouter", "true") } + { _make_menu(partitions, "Enlever", "false")} +
    +
    + - + """ ) for partition in partitions: if partition["partition_name"]: - H.append("" % partition["partition_name"]) - H.append("") + H.append(f"") + H.append("") for ins in inscrits: etud = ins["etud"] @@ -178,24 +181,20 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): else: checked = "" H.append( - """""") - groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre_id) + groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre.id) for partition in partitions: if partition["partition_name"]: gr_name = "" @@ -205,11 +204,11 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): break # gr_name == '' si etud non inscrit dans un groupe de cette partition H.append(f"") - H.append("""
    NomNom%s
    {partition['partition_name']}
    """ - % (etud["etudid"], checked) + f"""
    """ ) H.append( - """%s""" - % ( + f"""{etud['nomprenom']}""" ) H.append("""{gr_name}
    """) + H.append("""""") else: # SUBMISSION # inscrit a ce module tous les etuds selectionnes sco_moduleimpl.do_moduleimpl_inscrit_etuds( - moduleimpl_id, formsemestre_id, etuds, reset=True + moduleimpl_id, formsemestre.id, etudids, reset=True ) return flask.redirect( url_for( @@ -225,10 +224,10 @@ def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): def _make_menu(partitions: list[dict], title="", check="true") -> str: """Menu with list of all groups""" - items = [{"title": "Tous", "attr": "onclick=\"group_select('', -1, %s)\"" % check}] + items = [{"title": "Tous", "attr": f"onclick=\"group_select('', -1, {check})\""}] p_idx = 0 for partition in partitions: - if partition["partition_name"] != None: + if partition["partition_name"] is not None: p_idx += 1 for group in sco_groups.get_partition_groups(partition): items.append( @@ -240,9 +239,9 @@ def _make_menu(partitions: list[dict], title="", check="true") -> str: } ) return ( - '' + '
    ' + htmlutils.make_menu(title, items, alone=True) - + "" + + "
    " ) @@ -420,9 +419,11 @@ def moduleimpl_inscriptions_stats(formsemestre_id): for info in ues_cap_info[ue["ue_id"]]: etud = sco_etud.get_etud_info(etudid=info["etudid"], filled=True)[0] H.append( - f"""
  • H.append( f""" str: [ f"""{etud.nomprenom}""" for etud in sorted(etuds, key=attrgetter("sort_key")) diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index d3f83c28..e97716b6 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -134,7 +134,8 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str: if evaluation.date_fin else "", }, - "enabled": evaluation.date_debut is not None, + "enabled": evaluation.date_debut is not None + and evaluation.date_fin is not None, }, { "title": "Vérifier notes vs absents", @@ -167,6 +168,7 @@ def _ue_coefs_html(coefs_lst) -> str: {'background-color: ' + ue.color + ';' if ue.color else ''} ">
    {coef}
    {ue.acronyme}
""" for ue, coef in coefs_lst + if coef > 0 ] ) + "" diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 2a23059f..2a317add 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -25,38 +25,37 @@ # ############################################################################## -"""ScoDoc ficheEtud +"""ScoDoc fiche_etud Fiche description d'un étudiant et de son parcours """ -from flask import abort, url_for, g, render_template, request +from flask import url_for, g, render_template, request from flask_login import current_user +import sqlalchemy as sa -from app import db, log +from app import log +from app.auth.models import User from app.but import cursus_but -from app.models.etudiants import make_etud_args -from app.models import Identite, FormSemestre, ScoDocSiteConfig -from app.scodoc import html_sco_header -from app.scodoc import htmlutils -from app.scodoc import sco_archives_etud -from app.scodoc import sco_bac -from app.scodoc import codes_cursus -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_status -from app.scodoc import sco_groups -from app.scodoc import sco_cursus -from app.scodoc import sco_permissions_check -from app.scodoc import sco_photos -from app.scodoc import sco_users -from app.scodoc import sco_report -from app.scodoc import sco_etud +from app.models import Adresse, EtudAnnotation, FormSemestre, Identite, ScoDocSiteConfig +from app.scodoc import ( + codes_cursus, + html_sco_header, + htmlutils, + sco_archives_etud, + sco_bac, + sco_cursus, + sco_etud, + sco_groups, + sco_permissions_check, + sco_report, +) +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_bulletins import etud_descr_situation_semestre from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table from app.scodoc.sco_permissions import Permission import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb def _menu_scolarite( @@ -157,29 +156,18 @@ def _menu_scolarite( ) -def ficheEtud(etudid=None): +def fiche_etud(etudid=None): "fiche d'informations sur un etudiant" - authuser = current_user - cnx = ndb.GetDBConnexion() - if etudid: - try: # pour les bookmarks avec d'anciens ids... - etudid = int(etudid) - except ValueError: - raise ScoValueError("id invalide !") from ValueError - # la sidebar est differente s'il y a ou pas un etudid - # voir html_sidebar.sidebar() - g.etudid = etudid - args = make_etud_args(etudid=etudid) - etuds = sco_etud.etudident_list(cnx, args) - if not etuds: - log(f"ficheEtud: etudid={etudid!r} request.args={request.args!r}") - raise ScoValueError("Étudiant inexistant !") - etud_ = etuds[0] # transition: etud_ à éliminer et remplacer par etud - etudid = etud_["etudid"] - etud = Identite.get_etud(etudid) - sco_etud.fill_etuds_info([etud_]) - # - info = etud_ + restrict_etud_data = not current_user.has_permission(Permission.ViewEtudData) + try: + etud = Identite.get_etud(etudid) + except Exception as exc: + log(f"fiche_etud: etudid={etudid!r} request.args={request.args!r}") + raise ScoValueError("Étudiant inexistant !") from exc + # la sidebar est differente s'il y a ou pas un etudid + # voir html_sidebar.sidebar() + g.etudid = etudid + info = etud.to_dict_scodoc7(restrict=restrict_etud_data) if etud.prenom_etat_civil: info["etat_civil"] = ( "

Etat-civil: " @@ -193,45 +181,26 @@ def ficheEtud(etudid=None): else: info["etat_civil"] = "" info["ScoURL"] = scu.ScoURL() - info["authuser"] = authuser - info["info_naissance"] = info["date_naissance"] - if info["lieu_naissance"]: - info["info_naissance"] += " à " + info["lieu_naissance"] - if info["dept_naissance"]: - info["info_naissance"] += f" ({info['dept_naissance']})" - info["etudfoto"] = sco_photos.etud_photo_html(etud_) - if ( - (not info["domicile"]) - and (not info["codepostaldomicile"]) - and (not info["villedomicile"]) - ): - info["domicile"] = "inconnue" - if info["paysdomicile"]: - pays = sco_etud.format_pays(info["paysdomicile"]) - if pays: - info["paysdomicile"] = "(%s)" % pays - else: - info["paysdomicile"] = "" - if info["telephone"] or info["telephonemobile"]: - info["telephones"] = "
%s    %s" % ( - info["telephonestr"], - info["telephonemobilestr"], - ) + info["authuser"] = current_user + if restrict_etud_data: + info["info_naissance"] = "" + adresse = None else: - info["telephones"] = "" - # e-mail: - if info["email_default"]: - info["emaillink"] = ", ".join( - [ - '%s' % (m, m) - for m in [etud_["email"], etud_["emailperso"]] - if m - ] - ) - else: - info["emaillink"] = "(pas d'adresse e-mail)" + info["info_naissance"] = info["date_naissance"] + if info["lieu_naissance"]: + info["info_naissance"] += " à " + info["lieu_naissance"] + if info["dept_naissance"]: + info["info_naissance"] += f" ({info['dept_naissance']})" + adresse = etud.adresses.first() + info.update(_format_adresse(adresse)) + + info.update(etud.inscription_descr()) + info["etudfoto"] = etud.photo_html() + # Champ dépendant des permissions: - if authuser.has_permission(Permission.EtudChangeAdr): + if current_user.has_permission( + Permission.EtudChangeAdr + ) and current_user.has_permission(Permission.ViewEtudData): info[ "modifadresse" ] = f"""{descr["situation"]}""" else: e = {"etudid": etudid} - sco_groups.etud_add_group_infos( - e, - sem["formsemestre_id"], - only_to_show=True, - ) + sco_groups.etud_add_group_infos(e, formsemestre.id, only_to_show=True) grlinks = [] for partition in e["partitions"].values(): @@ -289,16 +255,16 @@ def ficheEtud(etudid=None): ) grlink = ", ".join(grlinks) # infos ajoutées au semestre dans le parcours (groupe, menu) - menu = _menu_scolarite(authuser, formsemestre, etudid, sem["ins"]["etat"]) + menu = _menu_scolarite(current_user, formsemestre, etudid, inscription.etat) if menu: - sem_info[sem["formsemestre_id"]] = ( + sem_info[formsemestre.id] = ( "
" + grlink + "" + menu + "
" ) else: - sem_info[sem["formsemestre_id"]] = grlink + sem_info[formsemestre.id] = grlink - if info["sems"]: - Se = sco_cursus.get_situation_etud_cursus(etud_, info["last_formsemestre_id"]) + if inscriptions: + Se = sco_cursus.get_situation_etud_cursus(info, info["last_formsemestre_id"]) info["liste_inscriptions"] = formsemestre_recap_parcours_table( Se, etudid, @@ -318,20 +284,19 @@ def ficheEtud(etudid=None): """ ) - last_formsemestre: FormSemestre = db.session.get( - FormSemestre, info["sems"][0]["formsemestre_id"] - ) + last_formsemestre: FormSemestre = inscriptions[0].formsemestre if last_formsemestre.formation.is_apc() and last_formsemestre.semestre_id > 2: info[ "link_bul_pdf" ] += f"""
Visualiser les compétences BUT """ - if authuser.has_permission(Permission.EtudInscrit): + if current_user.has_permission(Permission.EtudInscrit): info[ "link_inscrire_ailleurs" ] = f"""Étudiant{info["ne"]} non inscrit{info["ne"]}"""] - if authuser.has_permission(Permission.EtudInscrit): + l = [f"""

Étudiant{etud.e} non inscrit{etud.e}"""] + if current_user.has_permission(Permission.EtudInscrit): l.append( f"""%s' - % ( - etudid, - a["id"], - scu.icontag( + annotations_list = [] + annotations = EtudAnnotation.query.filter_by(etudid=etud.id).order_by( + sa.desc(EtudAnnotation.date) + ) + for annot in annotations: + del_link = ( + f"""{ + scu.icontag( "delete_img", border="0", alt="suppress", title="Supprimer cette annotation", - ), ) - ) - author = sco_users.user_info(a["author"]) - alist.append( - f"""Le {a['date']} par {author['prenomnom']} : - {a['comment']}{a['dellink']} + }""" + if sco_permissions_check.can_suppress_annotation(annot.id) + else "" + ) + + author = User.query.filter_by(user_name=annot.author).first() + annotations_list.append( + f"""Le {annot.date.strftime("%d/%m/%Y") if annot.date else "?"} + par {author.get_prenomnom() if author else "?"} : + {annot.comment or ""}{del_link} """ ) - info["liste_annotations"] = "\n".join(alist) + info["liste_annotations"] = "\n".join(annotations_list) # fiche admission - has_adm_notes = ( - info["math"] or info["physique"] or info["anglais"] or info["francais"] + infos_admission = _infos_admission(etud, restrict_etud_data) + has_adm_notes = any( + infos_admission[k] for k in ("math", "physique", "anglais", "francais") ) - has_bac_info = ( - info["bac"] - or info["specialite"] - or info["annee_bac"] - or info["rapporteur"] - or info["commentaire"] - or info["classement"] - or info["type_admission"] + has_bac_info = any( + infos_admission[k] + for k in ( + "bac_specialite", + "annee_bac", + "rapporteur", + "commentaire", + "classement", + "type_admission", + "rap", + ) ) if has_bac_info or has_adm_notes: adm_tmpl = """ @@ -411,7 +382,7 @@ def ficheEtud(etudid=None): BacAnnéeRg MathPhysiqueAnglaisFrançais -%(bac)s (%(specialite)s) +%(bac_specialite)s %(annee_bac)s %(classement)s %(math)s%(physique)s%(anglais)s%(francais)s @@ -419,27 +390,31 @@ def ficheEtud(etudid=None): """ adm_tmpl += """ -

Bac %(bac)s (%(specialite)s) obtenu en %(annee_bac)s
-
%(ilycee)s
""" - if info["type_admission"] or info["classement"]: +
Bac %(bac_specialite)s obtenu en %(annee_bac)s
+
%(info_lycee)s
""" + if infos_admission["type_admission"] or infos_admission["classement"]: adm_tmpl += """
""" - if info["type_admission"]: + if infos_admission["type_admission"]: adm_tmpl += """Voie d'admission: %(type_admission)s """ - if info["classement"]: + if infos_admission["classement"]: adm_tmpl += """Rang admission: %(classement)s""" - if info["type_admission"] or info["classement"]: + if infos_admission["type_admission"] or infos_admission["classement"]: adm_tmpl += "
" - if info["rap"]: + if infos_admission["rap"]: adm_tmpl += """
%(rap)s
""" adm_tmpl += """""" else: adm_tmpl = "" # pas de boite "info admission" - info["adm_data"] = adm_tmpl % info + info["adm_data"] = adm_tmpl % infos_admission # Fichiers archivés: info["fichiers_archive_htm"] = ( - '
Fichiers associés
' - + sco_archives_etud.etud_list_archives_html(etud) + "" + if restrict_etud_data + else ( + '
Fichiers associés
' + + sco_archives_etud.etud_list_archives_html(etud) + ) ) # Devenir de l'étudiant: @@ -455,18 +430,16 @@ def ficheEtud(etudid=None): if has_debouche: info[ "debouche_html" - ] = """
+ ] = f"""
Devenir:
    - %s + {link_add_suivi}
-
""" % ( - suivi_readonly, - info["etudid"], - link_add_suivi, - ) +
""" else: info["debouche_html"] = "" # pas de boite "devenir" # @@ -492,70 +465,92 @@ def ficheEtud(etudid=None): else: info["groupes_row"] = "" info["menus_etud"] = menus_etud(etudid) - if info["boursier"]: + if info["boursier"] and not restrict_etud_data: info["bourse_span"] = """boursier""" else: info["bourse_span"] = "" - # raccordement provisoire pour juillet 2022, avant refonte complète de cette fiche... - # info["but_infos_mkup"] = jury_but_view.infos_fiche_etud_html(etudid) - - # XXX dev - info["but_cursus_mkup"] = "" - if info["sems"]: - last_sem = FormSemestre.query.get_or_404(info["sems"][0]["formsemestre_id"]) - if last_sem.formation.is_apc(): - but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation) - info[ - "but_cursus_mkup" - ] = f""" -
- {render_template( - "but/cursus_etud.j2", - cursus=but_cursus, - scu=scu, - )} - + # Liens vers compétences BUT + if last_formsemestre and last_formsemestre.formation.is_apc(): + but_cursus = cursus_but.EtudCursusBUT(etud, last_formsemestre.formation) + info[ + "but_cursus_mkup" + ] = f""" +
+ {render_template( + "but/cursus_etud.j2", + cursus=but_cursus, + scu=scu, + )} + - """ +
+ """ + else: + info["but_cursus_mkup"] = "" - tmpl = """ -
+ adresse_template = ( + "" + if restrict_etud_data + else """ + +
+
+ + + + +
Adresse : %(domicile)s %(codepostaldomicile)s %(villedomicile)s %(paysdomicile)s + %(modifadresse)s + %(telephones)s +
+
+ """ + ) + + info_naissance = ( + f"""Né{etud.e} le :{info["info_naissance"]}""" + if info["info_naissance"] + else "" + ) + situation_template = ( + f""" +
+
+ + + %(groupes_row)s + {info_naissance} +
Situation :%(situation)s %(bourse_span)s
+ """ + + adresse_template + + """ +
+
+ """ + ) + + tmpl = ( + """ +

%(nomprenom)s (%(inscription)s)

%(etat_civil)s -%(emaillink)s +%(email_link)s
%(etudfoto)s
- -
-
- - -%(groupes_row)s - -
Situation :%(situation)s %(bourse_span)s
Né%(ne)s le :%(info_naissance)s
- - - -
- -
Adresse : %(domicile)s %(codepostaldomicile)s %(villedomicile)s %(paysdomicile)s -%(modifadresse)s -%(telephones)s -
-
-
-
+""" + + situation_template + + """ %(inscriptions_mkup)s @@ -595,8 +590,9 @@ def ficheEtud(etudid=None):
""" + ) header = html_sco_header.sco_header( - page_title="Fiche étudiant %(prenom)s %(nom)s" % info, + page_title=f"Fiche étudiant {etud.nomprenom}", cssstyles=[ "libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/jury_but.css", @@ -614,6 +610,92 @@ def ficheEtud(etudid=None): return header + tmpl % info + html_sco_header.sco_footer() +def _format_adresse(adresse: Adresse | None) -> dict: + """{ "telephonestr" : ..., "telephonemobilestr" : ... } (formats html)""" + d = { + "telephonestr": ("Tél.: " + scu.format_telephone(adresse.telephone)) + if (adresse and adresse.telephone) + else "", + "telephonemobilestr": ( + "Mobile: " + scu.format_telephone(adresse.telephonemobile) + ) + if (adresse and adresse.telephonemobile) + else "", + # e-mail: + "email_link": ", ".join( + [ + f"""{m}""" + for m in [adresse.email, adresse.emailperso] + if m + ] + ) + if adresse and (adresse.email or adresse.emailperso) + else "", + "domicile": (adresse.domicile or "") + if adresse + and (adresse.domicile or adresse.codepostaldomicile or adresse.villedomicile) + else "inconnue", + "paysdomicile": f"{sco_etud.format_pays(adresse.paysdomicile)}" + if adresse and adresse.paysdomicile + else "", + } + d["telephones"] = ( + f"
{d['telephonestr']}    {d['telephonemobilestr']}" + if adresse and (adresse.telephone or adresse.telephonemobile) + else "" + ) + return d + + +def _infos_admission(etud: Identite, restrict_etud_data: bool) -> dict: + """dict with adminission data, restricted or not""" + # info sur rapporteur et son commentaire + rap = "" + if not restrict_etud_data: + if etud.admission.rapporteur or etud.admission.commentaire: + rap = "Note du rapporteur" + if etud.admission.rapporteur: + rap += f" ({etud.admission.rapporteur})" + rap += ": " + if etud.admission.commentaire: + rap += f"{etud.admission.commentaire}" + # nom du lycée + if restrict_etud_data: + info_lycee = "" + elif etud.admission.nomlycee: + info_lycee = "Lycée " + sco_etud.format_lycee(etud.admission.nomlycee) + if etud.admission.villelycee: + info_lycee += f" ({etud.admission.villelycee})" + info_lycee += "
" + elif etud.admission.codelycee: + info_lycee = sco_etud.format_lycee_from_code(etud.admission.codelycee) + else: + info_lycee = "" + + return { + # infos accessibles à tous: + "bac_specialite": f"{etud.admission.bac or ''}{(' '+(etud.admission.specialite or '')) if etud.admission.specialite else ''}", + "annee_bac": etud.admission.annee_bac or "", + # infos protégées par ViewEtudData: + "info_lycee": info_lycee, + "rapporteur": etud.admission.rapporteur if not restrict_etud_data else "", + "rap": rap, + "commentaire": (etud.admission.commentaire or "") + if not restrict_etud_data + else "", + "classement": (etud.admission.classement or "") + if not restrict_etud_data + else "", + "type_admission": (etud.admission.type_admission or "") + if not restrict_etud_data + else "", + "math": (etud.admission.math or "") if not restrict_etud_data else "", + "physique": (etud.admission.physique or "") if not restrict_etud_data else "", + "anglais": (etud.admission.anglais or "") if not restrict_etud_data else "", + "francais": (etud.admission.francais or "") if not restrict_etud_data else "", + } + + def menus_etud(etudid): """Menu etudiant (operations sur l'etudiant)""" authuser = current_user @@ -623,7 +705,7 @@ def menus_etud(etudid): menuEtud = [ { "title": etud["nomprenom"], - "endpoint": "scolar.ficheEtud", + "endpoint": "scolar.fiche_etud", "args": {"etudid": etud["etudid"]}, "enabled": True, "helpmsg": "Fiche étudiant", @@ -638,7 +720,8 @@ def menus_etud(etudid): "title": "Changer les données identité/admission", "endpoint": "scolar.etudident_edit_form", "args": {"etudid": etud["etudid"]}, - "enabled": authuser.has_permission(Permission.EtudInscrit), + "enabled": authuser.has_permission(Permission.EtudInscrit) + and authuser.has_permission(Permission.ViewEtudData), }, { "title": "Copier dans un autre département...", @@ -669,38 +752,39 @@ def etud_info_html(etudid, with_photo="1", debug=False): """An HTML div with basic information and links about this etud. Used for popups information windows. """ - formsemestre_id = sco_formsemestre_status.retreive_formsemestre_from_request() + formsemestre_id = retreive_formsemestre_from_request() with_photo = int(with_photo) - etuds = sco_etud.get_etud_info(filled=True) - if etuds: - etud = etuds[0] - else: - abort(404, "etudiant inconnu") - photo_html = sco_photos.etud_photo_html(etud, title="fiche de " + etud["nom"]) - # experimental: may be too slow to be here - code_cursus, _ = sco_report.get_code_cursus_etud(etud, prefix="S", separator=", ") + etud = Identite.get_etud(etudid) - bac = sco_bac.Baccalaureat(etud["bac"], etud["specialite"]) + photo_html = etud.photo_html(title="fiche de " + etud.nomprenom) + code_cursus, _ = sco_report.get_code_cursus_etud( + etud, formsemestres=etud.get_formsemestres(), prefix="S", separator=", " + ) + bac = sco_bac.Baccalaureat(etud.admission.bac, etud.admission.specialite) bac_abbrev = bac.abbrev() H = f"""
+ url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) + }">{etud.nomprenom}
Bac: {bac_abbrev}
{code_cursus}
""" # Informations sur l'etudiant dans le semestre courant: - sem = None if formsemestre_id: # un semestre est spécifié par la page - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - elif etud["cursem"]: # le semestre "en cours" pour l'étudiant - sem = etud["cursem"] - if sem: - groups = sco_groups.get_etud_groups(etudid, formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + else: + # le semestre "en cours" pour l'étudiant + inscription_courante = etud.inscription_courante() + formsemestre = ( + inscription_courante.formsemestre if inscription_courante else None + ) + + if formsemestre: + groups = sco_groups.get_etud_groups(etudid, formsemestre.id) grc = sco_groups.listgroups_abbrev(groups) - H += f"""
En S{sem["semestre_id"]}: {grc}
""" + H += f"""
En S{formsemestre.semestre_id}: {grc}
""" H += "
" # fin partie gauche (eid_left) if with_photo: H += '' + photo_html + "" diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index d371833c..6e5e53ee 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -24,7 +24,7 @@ _SCO_PERMISSIONS = ( (1 << 10, "EditAllNotes", "Modifier toutes les notes"), (1 << 11, "EditAllEvals", "Modifier toutes les évaluations"), (1 << 12, "EditFormSemestre", "Mettre en place une formation (créer un semestre)"), - (1 << 13, "AbsChange", "Saisir des absences"), + (1 << 13, "AbsChange", "Saisir des absences ou justificatifs"), (1 << 14, "AbsAddBillet", "Saisir des billets d'absences"), # changer adresse/photo ou pour envoyer bulletins par mail ou pour debouche (1 << 15, "EtudChangeAdr", "Changer les adresses d'étudiants"), @@ -37,7 +37,11 @@ _SCO_PERMISSIONS = ( # aussi pour demissions, diplomes: (1 << 17, "EtudInscrit", "Inscrire des étudiants"), # aussi pour archives: - (1 << 18, "EtudAddAnnotations", "Éditer les annotations"), + ( + 1 << 18, + "EtudAddAnnotations", + "Éditer les annotations (et fichiers) sur étudiants", + ), # inutilisée (1 << 19, "ScoEntrepriseView", "Voir la section 'entreprises'"), # inutilisée (1 << 20, "EntrepriseChange", "Modifier les entreprises"), # XXX inutilisée ? (1 << 21, "EditPVJury", "Éditer les PV de jury"), @@ -55,10 +59,15 @@ _SCO_PERMISSIONS = ( "Exporter les données de l'application relations entreprises", ), (1 << 29, "UsersChangeCASId", "Paramétrer l'id CAS"), + (1 << 30, "ViewEtudData", "Accéder aux données personnelles des étudiants"), # # XXX inutilisée ? (1 << 40, "EtudChangePhoto", "Modifier la photo d'un étudiant"), # Permissions du module Assiduité) - (1 << 50, "AbsJustifView", "Visualisation des fichiers justificatifs"), + ( + 1 << 50, + "AbsJustifView", + "Visualisation du détail des justificatifs (motif, fichiers)", + ), # Attention: les permissions sont codées sur 64 bits. ) diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index cfb4a2ed..ba866e22 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -38,20 +38,19 @@ from app.comp.res_compat import NotesTableCompat from app.models import FormSemestre import app.scodoc.sco_utils as scu from app.scodoc import sco_assiduites -from app.scodoc import sco_cache from app.scodoc import sco_formsemestre from app.scodoc import sco_groups from app.scodoc import sco_preferences from app.scodoc import sco_etud -import sco_version from app.scodoc.gen_tables import GenTable from app.scodoc.codes_cursus import code_semestre_validant, code_semestre_attente +import sco_version -def etud_get_poursuite_info(sem, etud): +def etud_get_poursuite_info(sem: dict, etud: dict) -> dict: """{ 'nom' : ..., 'semlist' : [ { 'semestre_id': , 'moy' : ... }, {}, ...] }""" - I = {} - I.update(etud) # copie nom, prenom, civilite, ... + infos = {} + infos.update(etud) # copie nom, prenom, civilite, ... # Now add each semester, starting from the first one semlist = [] @@ -92,25 +91,28 @@ def etud_get_poursuite_info(sem, etud): for ue in ues: # on parcourt chaque UE for modimpl in modimpls: # dans chaque UE les modules if modimpl["module"]["ue_id"] == ue["ue_id"]: - codeModule = modimpl["module"]["code"] or "" - noteModule = scu.fmt_note( + code_module = modimpl["module"]["code"] or "" + note_module = scu.fmt_note( nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) ) - if noteModule != "NI": # si étudiant inscrit au module + # si étudiant inscrit au module, sauf BUT + if (note_module != "NI") and not nt.is_apc: if nt.mod_rangs is not None: - rangModule = nt.mod_rangs[modimpl["moduleimpl_id"]][ - 0 - ][etudid] + rang_module = nt.mod_rangs[ + modimpl["moduleimpl_id"] + ][0][etudid] else: - rangModule = "" - modules.append([codeModule, noteModule]) - rangs.append(["rang_" + codeModule, rangModule]) + rang_module = "" + modules.append([code_module, note_module]) + rangs.append(["rang_" + code_module, rang_module]) # Absences nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, nt.sem) - if ( + # En BUT, prend tout, sinon ne prend que les semestre validés par le jury + if nt.is_apc or ( dec - and not sem_descr # not sem_descr pour ne prendre que le semestre validé le plus récent + # not sem_descr pour ne prendre que le semestre validé le plus récent: + and not sem_descr and ( code_semestre_validant(dec["code"]) or code_semestre_attente(dec["code"]) @@ -128,9 +130,8 @@ def etud_get_poursuite_info(sem, etud): ("AbsNonJust", nbabs - nbabsjust), ("AbsJust", nbabsjust), ] - d += ( - moy_ues + rg_ues + modules + rangs - ) # ajout des 2 champs notes des modules et classement dans chaque module + # ajout des 2 champs notes des modules et classement dans chaque module + d += moy_ues + rg_ues + modules + rangs sem_descr = collections.OrderedDict(d) if not sem_descr: sem_descr = collections.OrderedDict( @@ -147,13 +148,14 @@ def etud_get_poursuite_info(sem, etud): sem_descr["semestre_id"] = sem_id semlist.append(sem_descr) - I["semlist"] = semlist - return I + infos["semlist"] = semlist + return infos def _flatten_info(info): - # met la liste des infos semestres "a plat" - # S1_moy, S1_rang, ..., S2_moy, ... + """met la liste des infos semestres "a plat" + S1_moy, S1_rang, ..., S2_moy, ... + """ ids = [] for s in info["semlist"]: for k, v in s.items(): @@ -164,7 +166,7 @@ def _flatten_info(info): return ids -def _getEtudInfoGroupes(group_ids, etat=None): +def _get_etud_info_groupes(group_ids, etat=None): """liste triée d'infos (dict) sur les etudiants du groupe indiqué. Attention: lent, car plusieurs requetes SQL par etudiant ! """ @@ -181,17 +183,17 @@ def _getEtudInfoGroupes(group_ids, etat=None): def formsemestre_poursuite_report(formsemestre_id, fmt="html"): """Table avec informations "poursuite" """ sem = sco_formsemestre.get_formsemestre(formsemestre_id) - etuds = _getEtudInfoGroupes([sco_groups.get_default_group(formsemestre_id)]) + etuds = _get_etud_info_groupes([sco_groups.get_default_group(formsemestre_id)]) infos = [] ids = [] for etud in etuds: fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + etud["_nom_td_attrs"] = f"""id="{etud['etudid']}" class="etudinfo" """ info = etud_get_poursuite_info(sem, etud) idd = _flatten_info(info) # On recupere la totalite des UEs dans ids diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index ede0698a..0072142d 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -147,6 +147,7 @@ def get_preference(name, formsemestre_id=None, dept_id=None): """Returns value of named preference. All preferences have a sensible default value, so this function always returns a usable value for all defined preferences names. + If dept_id is None, use current dept (g.scodoc_dept_id) """ return get_base_preferences(dept_id=dept_id).get(formsemestre_id, name) @@ -541,18 +542,6 @@ class BasePreferences: "category": "abs", }, ), - ( - "abs_notify_max_freq", - { - "initvalue": 7, - "title": "Fréquence maximale de notification", - "explanation": "nb de jours minimum entre deux mails envoyés au même destinataire à propos d'un même étudiant ", - "size": 4, - "type": "int", - "convert_numbers": True, - "category": "abs", - }, - ), ( "abs_notify_abs_threshold", { @@ -710,11 +699,23 @@ class BasePreferences: "size": 10, "title": "Seuil d'alerte des absences", "type": "int", - "explanation": "Nombres d'absences limite avant alerte dans le bilan (utilisation de l'unité métrique ↑ )", + "explanation": "Nombres d'absences limite avant alerte (utilisation de l'unité métrique ↑ )", "category": "assi", "only_global": True, }, ), + ( + "abs_notify_max_freq", + { + "initvalue": 7, + "title": "Fréquence maximale de notification", + "explanation": "nb de jours minimum entre deux mails envoyés au même destinataire à propos d'un même étudiant ", + "size": 4, + "type": "int", + "convert_numbers": True, + "category": "abs", + }, + ), # portal ( "portal_url", diff --git a/app/scodoc/sco_pv_forms.py b/app/scodoc/sco_pv_forms.py index 54722be1..ce5e624c 100644 --- a/app/scodoc/sco_pv_forms.py +++ b/app/scodoc/sco_pv_forms.py @@ -144,7 +144,7 @@ def pvjury_table( "code_nip": e["identite"]["code_nip"], "nomprenom": e["identite"]["nomprenom"], "_nomprenom_target": url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["identite"]["etudid"], ), @@ -351,7 +351,7 @@ def formsemestre_pvjury_pdf(formsemestre_id, group_ids: list[int] = None, etudid # PV pour ce seul étudiant: etud = Identite.get_etud(etudid) etuddescr = f"""{etud.nomprenom}""" etudids = [etudid] else: diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index d532ea2c..75caad14 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -1017,34 +1017,60 @@ EXP_LIC = re.compile(r"licence", re.I) EXP_LPRO = re.compile(r"professionnelle", re.I) -def _codesem(sem, short=True, prefix=""): +def _code_sem( + semestre_id: int, titre: str, mois_debut: int, short=True, prefix="" +) -> str: "code semestre: S1 ou S1d" - idx = sem["semestre_id"] + idx = semestre_id # semestre décalé ? # les semestres pairs normaux commencent entre janvier et mars # les impairs normaux entre aout et decembre d = "" - if idx and idx > 0 and sem["date_debut"]: - mois_debut = int(sem["date_debut"].split("/")[1]) + if idx > 0: if (idx % 2 and mois_debut < 3) or (idx % 2 == 0 and mois_debut >= 8): d = "d" if idx == -1: if short: idx = "Autre " else: - idx = sem["titre"] + " " + idx = titre + " " idx = EXP_LPRO.sub("pro.", idx) idx = EXP_LIC.sub("Lic.", idx) prefix = "" # indique titre au lieu de Sn - return "%s%s%s" % (prefix, idx, d) + return prefix + str(idx) + d -def get_code_cursus_etud(etud, prefix="", separator=""): +def _code_sem_formsemestre(formsemestre: FormSemestre, short=True, prefix="") -> str: + "code semestre: S1 ou S1d" + titre = formsemestre.titre + mois_debut = formsemestre.date_debut.month + semestre_id = formsemestre.semestre_id + return _code_sem(semestre_id, titre, mois_debut, short=short, prefix=prefix) + + +def _code_sem_dict(sem, short=True, prefix="") -> str: + "code semestre: S1 ou S1d, à parit d'un dict (sem ScoDoc 7)" + titre = sem["titre"] + mois_debut = int(sem["date_debut"].split("/")[1]) if sem["date_debut"] else 0 + semestre_id = sem["semestre_id"] + return _code_sem(semestre_id, titre, mois_debut, short=short, prefix=prefix) + + +def get_code_cursus_etud( + etudid: int, + sems: list[dict] = None, + formsemestres: list[FormSemestre] | None = None, + prefix="", + separator="", +) -> tuple[str, dict]: """calcule un code de cursus (parcours) pour un etudiant exemples: 1234A pour un etudiant ayant effectué S1, S2, S3, S4 puis diplome 12D pour un étudiant en S1, S2 puis démission en S2 12R pour un etudiant en S1, S2 réorienté en fin de S2 + + On peut passer soir la liste des semestres dict (anciennes fonctions ScoDoc7) + soit la liste des FormSemestre. Construit aussi un dict: { semestre_id : decision_jury | None } """ # Nota: approche plus moderne: @@ -1054,36 +1080,42 @@ def get_code_cursus_etud(etud, prefix="", separator=""): # p = [] decisions_jury = {} - # élimine les semestres spéciaux hors cursus (LP en 1 sem., ...) - sems = [s for s in etud["sems"] if s["semestre_id"] >= 0] - i = len(sems) - 1 - while i >= 0: - s = sems[i] # 'sems' est a l'envers, du plus recent au plus ancien - s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) - p.append(_codesem(s, prefix=prefix)) + if formsemestres is None: + formsemestres = [ + FormSemestre.query.get_or_404(s["formsemestre_id"]) for s in (sems or []) + ] + + # élimine les semestres spéciaux hors cursus (LP en 1 sem., ...) + formsemestres = [s for s in formsemestres if s.semestre_id >= 0] + i = len(formsemestres) - 1 + while i >= 0: + # 'sems' est a l'envers, du plus recent au plus ancien + formsemestre = formsemestres[i] + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + p.append(_code_sem_formsemestre(formsemestre, prefix=prefix)) # code decisions jury de chaque semestre: - if nt.get_etud_etat(etud["etudid"]) == "D": - decisions_jury[s["semestre_id"]] = "DEM" + if nt.get_etud_etat(etudid) == "D": + decisions_jury[formsemestre.semestre_id] = "DEM" else: - dec = nt.get_etud_decision_sem(etud["etudid"]) + dec = nt.get_etud_decision_sem(etudid) if not dec: - decisions_jury[s["semestre_id"]] = "" + decisions_jury[formsemestre.semestre_id] = "" else: - decisions_jury[s["semestre_id"]] = dec["code"] + decisions_jury[formsemestre.semestre_id] = dec["code"] # code etat dans le code_cursus sur dernier semestre seulement if i == 0: # Démission - if nt.get_etud_etat(etud["etudid"]) == "D": + if nt.get_etud_etat(etudid) == "D": p.append(":D") else: - dec = nt.get_etud_decision_sem(etud["etudid"]) + dec = nt.get_etud_decision_sem(etudid) if dec and dec["code"] in codes_cursus.CODES_SEM_REO: p.append(":R") if ( dec - and s["semestre_id"] == nt.parcours.NB_SEM + and formsemestre.semestre_id == nt.parcours.NB_SEM and code_semestre_validant(dec["code"]) ): p.append(":A") @@ -1176,14 +1208,16 @@ def table_suivi_cursus(formsemestre_id, only_primo=False, grouped_parcours=True) ) = tsp_etud_list(formsemestre_id, only_primo=only_primo) codes_etuds = collections.defaultdict(list) for etud in etuds: - etud["code_cursus"], etud["decisions_jury"] = get_code_cursus_etud(etud) + etud["code_cursus"], etud["decisions_jury"] = get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) codes_etuds[etud["code_cursus"]].append(etud) fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + etud["_nom_td_attrs"] = f'''id="{etud['etudid']}" class="etudinfo"''' titles = { "parcours": "Code cursus", @@ -1461,7 +1495,7 @@ def graph_cursus( else: modalite = "" label = "%s%s\\n%d/%s - %d/%s\\n%d" % ( - _codesem(s, short=False, prefix="S"), + _code_sem_dict(s, short=False, prefix="S"), modalite, s["mois_debut_ord"], s["annee_debut"][2:], diff --git a/app/scodoc/sco_roles_default.py b/app/scodoc/sco_roles_default.py index 75cbdedd..a727a94b 100644 --- a/app/scodoc/sco_roles_default.py +++ b/app/scodoc/sco_roles_default.py @@ -13,8 +13,9 @@ SCO_ROLES_DEFAULTS = { p.EnsView, p.EtudAddAnnotations, p.Observateur, - p.UsersView, p.ScoView, + p.ViewEtudData, + p.UsersView, ), "Secr": ( p.AbsAddBillet, @@ -23,8 +24,9 @@ SCO_ROLES_DEFAULTS = { p.EtudAddAnnotations, p.EtudChangeAdr, p.Observateur, - p.UsersView, p.ScoView, + p.UsersView, + p.ViewEtudData, ), # Admin est le chef du département, pas le "super admin" # on doit donc lister toutes ses permissions: @@ -44,9 +46,10 @@ SCO_ROLES_DEFAULTS = { p.EtudInscrit, p.EditFormSemestre, p.Observateur, + p.ScoView, p.UsersAdmin, p.UsersView, - p.ScoView, + p.ViewEtudData, ), # Rôles pour l'application relations entreprises # ObservateurEntreprise est un observateur de l'application entreprise @@ -57,7 +60,8 @@ SCO_ROLES_DEFAULTS = { p.RelationsEntrepEdit, p.RelationsEntrepViewCorrs, ), - # AdminEntreprise est un admin de l'application entreprise (toutes les actions possibles de l'application) + # AdminEntreprise est un admin de l'application entreprise + # (toutes les actions possibles de l'application) "AdminEntreprise": ( p.RelationsEntrepView, p.RelationsEntrepEdit, diff --git a/app/scodoc/sco_trombino.py b/app/scodoc/sco_trombino.py index c197cefd..a77e6ec3 100644 --- a/app/scodoc/sco_trombino.py +++ b/app/scodoc/sco_trombino.py @@ -156,7 +156,7 @@ def trombino_html(groups_infos): '%s' % ( url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=t["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=t["etudid"] ), foto, ) diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 011038c2..88592561 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -279,7 +279,7 @@ class NonWorkDays(int, BiDirectionalEnum): ] -def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: +def is_iso_formated(date: str, convert=False) -> bool | datetime.datetime | None: """ Vérifie si une date est au format iso @@ -298,12 +298,11 @@ def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or No return None if convert else False -def localize_datetime(date: datetime.datetime or str) -> datetime.datetime: - """Ajoute un timecode UTC à la date donnée. - XXX semble faire autre chose... TODO fix this comment +def localize_datetime(date: datetime.datetime) -> datetime.datetime: + """Transforme une date sans offset en une date avec offset + Tente de mettre l'offset de la timezone du serveur (ex : UTC+1) + Si erreur, mettra l'offset UTC """ - if isinstance(date, str): - date = is_iso_formated(date, convert=True) new_date: datetime.datetime = date if new_date.tzinfo is None: @@ -428,7 +427,7 @@ APO_MISSING_CODE_STR = "----" # shown in HTML pages in place of missing code Ap EDIT_NB_ETAPES = 6 # Nombre max de codes étapes / semestre presentés dans l'UI IT_SITUATION_MISSING_STR = ( - "____" # shown on ficheEtud (devenir) in place of empty situation + "____" # shown on fiche_etud (devenir) in place of empty situation ) RANG_ATTENTE_STR = "(attente)" # rang affiché sur bulletins quand notes en attente @@ -476,7 +475,7 @@ MONTH_NAMES_ABBREV = ( "Avr ", "Mai ", "Juin", - "Jul ", + "Juil ", "Août", "Sept", "Oct ", @@ -1282,6 +1281,27 @@ def format_prenom(s): return " ".join(r) +def format_telephone(n: str | None) -> str: + "Format a phone number for display" + if n is None: + return "" + if len(n) < 7: + return n + n = n.replace(" ", "").replace(".", "") + i = 0 + r = "" + j = len(n) - 1 + while j >= 0: + r = n[j] + r + if i % 2 == 1 and j != 0: + r = " " + r + i += 1 + j -= 1 + if len(r) == 13 and r[0] != "0": + r = "0" + r + return r + + # def timedate_human_repr(): "representation du temps courant pour utilisateur" @@ -1610,20 +1630,12 @@ def is_entreprises_enabled(): def is_assiduites_module_forced( formsemestre_id: int = None, dept_id: int = None ) -> bool: + """Vrai si préférence "imposer la saisie du module" sur les assiduités est vraie.""" from app.scodoc import sco_preferences - retour: bool - - if dept_id is None: - dept_id = g.scodoc_dept_id - - try: - retour = sco_preferences.get_preference( - "forcer_module", formsemestre_id=int(formsemestre_id) - ) - except (TypeError, ValueError): - retour = sco_preferences.get_preference("forcer_module", dept_id=dept_id) - return retour + return sco_preferences.get_preference( + "forcer_module", formsemestre_id=formsemestre_id, dept_id=dept_id + ) def get_assiduites_time_config(config_type: str) -> str | int: diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css index a19f0d9e..da872491 100644 --- a/app/static/css/assiduites.css +++ b/app/static/css/assiduites.css @@ -256,17 +256,17 @@ background-color: var(--color-conflit); } -.etud_row .assiduites_bar .absent, +.etud_row .assiduites_bar>.absent, .demo.absent { background-color: var(--color-absent) !important; } -.etud_row .assiduites_bar .present, +.etud_row .assiduites_bar>.present, .demo.present { background-color: var(--color-present) !important; } -.etud_row .assiduites_bar .retard, +.etud_row .assiduites_bar>.retard, .demo.retard { background-color: var(--color-retard) !important; } @@ -275,12 +275,12 @@ background-color: var(--color-nonwork) !important; } -.etud_row .assiduites_bar .justified, +.etud_row .assiduites_bar>.justified, .demo.justified { background-image: var(--motif-justi); } -.etud_row .assiduites_bar .invalid_justified, +.etud_row .assiduites_bar>.invalid_justified, .demo.invalid_justified { background-image: var(--motif-justi-invalide); } diff --git a/app/static/css/edt.css b/app/static/css/edt.css index cfbf9ef7..4644be99 100644 --- a/app/static/css/edt.css +++ b/app/static/css/edt.css @@ -144,3 +144,7 @@ span.ens-non-reconnu { .btn:active { outline: none; } + +.raw-event { + display: none; +} \ No newline at end of file diff --git a/app/static/css/minitimeline.css b/app/static/css/minitimeline.css new file mode 100644 index 00000000..bc41337b --- /dev/null +++ b/app/static/css/minitimeline.css @@ -0,0 +1,212 @@ +.day .dayline { + position: absolute; + display: none; + top: 100%; + z-index: 50; + width: max-content; + height: 75px; + background-color: #dedede; + border-radius: 15px; + padding: 5px; +} + +.day:hover .dayline { + display: block; +} + +.dayline .mini-timeline { + margin-top: 10%; +} + +.dayline-title { + margin: 0; +} + +.dayline .mini_tick { + position: absolute; + text-align: center; + top: 0; + transform: translateY(-110%); + z-index: 50; +} + +.dayline .mini_tick::after { + display: block; + content: "|"; + position: absolute; + bottom: -69%; + z-index: 2; + transform: translateX(200%); +} + +#label-nom, +#label-justi { + display: none; +} + +.demi .day { + display: flex; + justify-content: space-evenly; +} + +.demi .day>span { + display: block; + flex: 1; + text-align: center; + z-index: 1; + width: 100%; + border: 1px solid #d5d5d5; + position: relative; +} + +.demi .day>span:first-of-type { + width: 3em; + min-width: 3em; +} + +.options>* { + margin-right: 5px; +} + +.options input { + margin-right: 6px; +} + +.options label { + font-weight: normal; + margin-right: 16px; +} + + +/*Gestion des bubbles*/ +.assiduite-bubble { + position: relative; + display: none; + background-color: #f9f9f9; + border-radius: 5px; + padding: 8px; + border: 3px solid #ccc; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + font-size: 12px; + line-height: 1.4; + z-index: 3; + min-width: max-content; + top: 200%; +} + +.mini-timeline-block:hover .assiduite-bubble { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + height: auto; + max-height: 150px; +} + +.assiduite-bubble::before { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 6px; + border-style: solid; + border-color: transparent transparent #f9f9f9 transparent; +} + +.assiduite-bubble::after { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 5px; + border-style: solid; + border-color: transparent transparent #ccc transparent; +} + +.assiduite-id, +.assiduite-period, +.assiduite-state, +.assiduite-user_id { + margin-bottom: 4px; +} + +.assiduite-bubble.absent { + border-color: var(--color-absent) !important; +} + +.assiduite-bubble.present { + border-color: var(--color-present) !important; +} + +.assiduite-bubble.retard { + border-color: var(--color-retard) !important; +} + +/*Gestion des minitimelines*/ +.mini-timeline { + height: 7px; + border: 1px solid black; + position: relative; + background-color: white; +} + +.mini-timeline.single { + height: 9px; +} + +.mini-timeline-block { + position: absolute; + height: 100%; + display: flex; + justify-content: flex-start; + align-items: center; + flex-direction: column; +} + +.mini-timeline-block { + cursor: pointer; +} + +.mini_tick { + position: absolute; + text-align: start; + top: -40px; + transform: translateX(-50%); + z-index: 1; + +} + +.mini_tick::after { + display: block; + content: "|"; + position: absolute; + bottom: -2px; + z-index: 1; +} + +.mini-timeline-block.creneau { + outline: 3px solid var(--color-primary); + pointer-events: none; +} + +.mini-timeline-block.absent { + background-color: var(--color-absent) !important; +} + +.mini-timeline-block.present { + background-color: var(--color-present) !important; +} + +.mini-timeline-block.retard { + background-color: var(--color-retard) !important; +} + +.mini-timeline-block.justified { + background-image: var(--motif-justi); +} + +.mini-timeline-block.invalid_justified { + background-image: var(--motif-justi-invalide); +} \ No newline at end of file diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index f42cc0f6..948554f2 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -172,6 +172,11 @@ form#group_selector { margin-bottom: 3px; } +/* Text lien ou itms ,non autorisés pour l'utilisateur courant */ +.unauthorized { + color: grey; +} + /* ----- bandeau haut ------ */ span.bandeaugtr { width: 100%; @@ -724,7 +729,7 @@ div.scoinfos { /* ----- fiches etudiants ------ */ -div.ficheEtud { +div.fiche_etud { background-color: #f5edc8; /* rgb(255,240,128); */ border: 1px solid gray; @@ -739,7 +744,7 @@ div.menus_etud { margin-top: 1px; } -div.ficheEtud h2 { +div.fiche_etud h2 { padding-top: 10px; } @@ -925,7 +930,7 @@ td.fichetitre2 { vertical-align: top; } -.ficheEtud span.boursier { +.fiche_etud span.boursier { background-color: red; color: white; margin-left: 12px; @@ -963,6 +968,7 @@ div.section_but { div.section_but > div.link_validation_rcues { align-self: center; + text-align: center; } .ficheannotations { @@ -1736,7 +1742,9 @@ formsemestre_page_title .lock img { width: 200px !important; } -span.inscr_addremove_menu { +div.inscr_addremove_menu { + display: inline-block; + margin: 8px 0px; width: 150px; } diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 9f7e69e7..8141dec3 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1,3 +1,4 @@ +// TODO : Supprimer les fonctions non utilisées + optimiser les fonctions utilisées // <=== CONSTANTS and GLOBALS ===> let url; @@ -68,6 +69,25 @@ function setupCheckBox(parent = document) { }); } +function updateEtudList() { + const group_ids = getGroupIds(); + etuds = {}; + group_ids.forEach((group_id) => { + sync_get(getUrl() + `/api/group/${group_id}/etudiants`, (data, status) => { + if (status === "success") { + data.forEach((etud) => { + if (!(etud.id in etuds)) { + etuds[etud.id] = etud; + } + }); + } + }); + }); + + getAssiduitesFromEtuds(true); + generateAllEtudRow(); +} + /** * Validation préalable puis désactivation des chammps : * - Groupe @@ -108,14 +128,16 @@ function validateSelectors(btn) { return; } - getAssiduitesFromEtuds(true); - - // document.querySelector(".selectors").disabled = true; - // $("#tl_date").datepicker("option", "disabled", true); generateMassAssiduites(); + + getAssiduitesFromEtuds(true); generateAllEtudRow(); - // btn.remove(); - btn.textContent = "Actualiser"; + + btn.remove(); + // Auto actualisation + $("#tl_date").on("change", updateEtudList); + $("#group_ids_sel").on("change", updateEtudList); + onlyAbs(); }; @@ -648,16 +670,15 @@ function updateDate() { ); openAlertModal("Attention", div, "", "#eec660"); - /* BUG TODO MATHIAS - $(dateInput).datepicker("setDate", date_fra); // XXX ??? non définie - dateInput.value = date_fra; - */ date = lastWorkDay; dateStr = formatDate(lastWorkDay, { dateStyle: "full", timeZone: SCO_TIMEZONE, }).capitalize(); + + $(dateInput).datepicker("setDate", date); + $(dateInput).change(); } document.querySelector("#datestr").textContent = dateStr; @@ -697,6 +718,61 @@ function setupDate(onchange = null) { } }); + //Initialisation du datepicker + // sinon on ne peut pas le mettre à jour + // XXX TODO-assiduite : finir tester + éviter duplication code avec scodoc.js + $(input).datepicker({ + showOn: "button", + buttonImage: "/ScoDoc/static/icons/calendar_img.png", + buttonImageOnly: true, + dateFormat: "dd/mm/yy", + duration: "fast", + firstDay: 1, // Start with Monday + dayNames: [ + "Dimanche", + "Lundi", + "Mardi", + "Mercredi", + "Jeudi", + "Vendredi", + "Samedi", + ], + dayNamesMin: ["Di", "Lu", "Ma", "Me", "Je", "Ve", "Sa"], + dayNamesShort: ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"], + monthNames: [ + "Janvier", + "Février", + "Mars", + "Avril", + "May", + "Juin", + "Juillet", + "Août", + "Septembre", + "Octobre", + "Novembre", + "Décembre", + ], + monthNamesShort: [ + "Jan", + "Fév", + "Mar", + "Avr", + "Mai", + "Juin", + "Juil", + "Aoû", + "Sep", + "Oct", + "Nov", + "Déc", + ], + }); + $(input).datepicker( + "option", + $.extend({ showMonthAfterYear: false }, $.datepicker.regional["fr"]) + ); + if (onchange != null) { $(input).change(onchange); } @@ -1262,19 +1338,14 @@ function getAllAssiduitesFromEtud( .replace("°", courant ? "&courant" : "") : "" }`; - //TODO Utiliser async_get au lieu de jquery - $.ajax({ - async: true, - type: "GET", - url: url_api, - success: (data, status) => { - if (status === "success") { - assiduites[etudid] = data; - action(data); - } + async_get( + url_api, + (data) => { + assiduites[etudid] = data; + action(data); }, - error: () => {}, - }); + (_) => {} + ); } /** @@ -1844,18 +1915,13 @@ function getAllJustificatifsFromEtud( order ? "/query?order°".replace("°", courant ? "&courant" : "") : "" }`; - //TODO Utiliser async_get au lieu de jquery - $.ajax({ - async: true, - type: "GET", - url: url_api, - success: (data, status) => { - if (status === "success") { - action(data); - } + async_get( + url_api, + (data) => { + action(data); }, - error: () => {}, - }); + () => {} + ); } function deleteJustificatif(justif_id) { diff --git a/app/static/js/scodoc.js b/app/static/js/scodoc.js index 0cb83e4c..bb3aafbf 100644 --- a/app/static/js/scodoc.js +++ b/app/static/js/scodoc.js @@ -39,7 +39,7 @@ $(function () { "Avril", "May", "Juin", - "Juilet", + "Juillet", "Août", "Septembre", "Octobre", diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index 17d04c27..e2f93788 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -1,14 +1,76 @@ from datetime import datetime from flask import url_for -from flask_sqlalchemy.query import Pagination, Query -from sqlalchemy import desc, literal, union +from flask_login import current_user +from flask_sqlalchemy.query import Query +from sqlalchemy import desc, literal, union, asc from app import db, g from app.auth.models import User from app.models import Assiduite, Identite, Justificatif from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool from app.tables import table_builder as tb +from app.scodoc.sco_cache import RequeteTableauAssiduiteCache +from app.scodoc.sco_permissions import Permission + + +class Pagination: + """ + Pagination d'une collection de données + + On donne : + - une collection de données (de préférence une liste / tuple) + - le numéro de page à afficher + - le nombre d'éléments par page + + On peut ensuite récupérer les éléments de la page courante avec la méthode `items()` + + Cette classe ne permet pas de changer de page. + (Pour cela, il faut créer une nouvelle instance, avec la collection originelle et la nouvelle page) + + l'intéret est de ne pas garder en mémoire toute la collection, mais seulement la page courante + + """ + + def __init__(self, collection: list, page: int = 1, per_page: int = -1): + """ + __init__ Instancie un nouvel objet Pagination + + Args: + collection (list): La collection à paginer. Il s'agit par exemple d'une requête + page (int, optional): le numéro de la page à voir. Defaults to 1. + per_page (int, optional): le nombre d'éléments par page. Defaults to -1. (-1 = pas de pagination/tout afficher) + """ + # par défaut le total des pages est 1 (même si la collection est vide) + self.total_pages = 1 + + if per_page != -1: + # on récupère le nombre de page complète et le reste + # q => nombre de page + # r => le nombre d'éléments restants (dernière page si != 0) + q, r = len(collection) // per_page, len(collection) % per_page + self.total_pages = q if r == 0 else q + 1 # q + 1 s'il reste des éléments + + # On s'assure que la page demandée est dans les limites + current_page: int = min(self.total_pages, page if page > 0 else 1) + + # On récupère la collection de la page courante + self.collection = ( + collection # toute la collection si pas de pagination + if per_page == -1 + else collection[ + per_page * (current_page - 1) : per_page * (current_page) + ] # sinon on récupère la page + ) + + def items(self) -> list: + """ + items Renvoi la collection de la page courante + + Returns: + list: la collection de la page courante + """ + return self.collection class ListeAssiJusti(tb.Table): @@ -18,13 +80,15 @@ class ListeAssiJusti(tb.Table): """ NB_PAR_PAGE: int = 25 - MAX_PAR_PAGE: int = 200 + MAX_PAR_PAGE: int = 1000 def __init__( self, table_data: "AssiJustifData", filtre: "AssiFiltre" = None, options: "AssiDisplayOptions" = None, + no_pagination: bool = False, + titre: str = "", **kwargs, ) -> None: """ @@ -41,11 +105,21 @@ class ListeAssiJusti(tb.Table): # Gestion des options, par défaut un objet Options vide self.options = options if options is not None else AssiDisplayOptions() + self.no_pagination: bool = no_pagination + self.total_page: int = None + # Accès aux détail des justificatifs ? + self.can_view_justif_detail = current_user.has_permission( + Permission.AbsJustifView + ) + # les lignes du tableau self.rows: list["RowAssiJusti"] = [] + # Titre du tableau, utilisé pour le cache + self.titre = titre + # Instanciation de la classe parent super().__init__( row_class=RowAssiJusti, @@ -65,59 +139,93 @@ class ListeAssiJusti(tb.Table): # Récupération du filtrage des objets -> 0 : tout, 1 : Assi, 2: Justi type_obj = self.filtre.type_obj() - if type_obj in [0, 1]: - assiduites_query_etudiants = self.table_data.assiduites_query - - # Non affichage des présences - if not self.options.show_pres: - assiduites_query_etudiants = assiduites_query_etudiants.filter( - Assiduite.etat != EtatAssiduite.PRESENT - ) - # Non affichage des retards - if not self.options.show_reta: - assiduites_query_etudiants = assiduites_query_etudiants.filter( - Assiduite.etat != EtatAssiduite.RETARD - ) - - if type_obj in [0, 2]: - justificatifs_query_etudiants = self.table_data.justificatifs_query - - # Combinaison des requêtes - - query_finale: Query = self.joindre( - query_assiduite=assiduites_query_etudiants, - query_justificatif=justificatifs_query_etudiants, + cle_cache: str = ":".join( + map( + str, + [ + self.titre, + type_obj, + self.options.show_pres, + self.options.show_reta, + self.options.show_desc, + self.options.order[0], + self.options.order[1], + ], + ) ) + r = RequeteTableauAssiduiteCache().get(cle_cache) + + if r is None: + if type_obj in [0, 1]: + assiduites_query_etudiants = self.table_data.assiduites_query + + # Non affichage des présences + if ( + not self.options.show_pres + and assiduites_query_etudiants is not None + ): + assiduites_query_etudiants = assiduites_query_etudiants.filter( + Assiduite.etat != EtatAssiduite.PRESENT + ) + # Non affichage des retards + if ( + not self.options.show_reta + and assiduites_query_etudiants is not None + ): + assiduites_query_etudiants = assiduites_query_etudiants.filter( + Assiduite.etat != EtatAssiduite.RETARD + ) + + if type_obj in [0, 2]: + justificatifs_query_etudiants = self.table_data.justificatifs_query + + # Combinaison des requêtes + + query_finale: Query = self.joindre( + query_assiduite=assiduites_query_etudiants, + query_justificatif=justificatifs_query_etudiants, + ) + + # Tri de la query si option + if self.options.order is not None: + order_sort: str = asc if self.options.order[1] else desc + order_col: str = self.options.order[0] + query_finale: Query = query_finale.order_by(order_sort(order_col)) + + r = query_finale.all() + RequeteTableauAssiduiteCache.set(cle_cache, r) # Paginer la requête pour ne pas envoyer trop d'informations au client - pagination: Pagination = self.paginer(query_finale) - self.total_pages: int = pagination.pages + pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination) + self.total_pages = pagination.total_pages # Générer les lignes de la page - for ligne in pagination.items: + for ligne in pagination.items(): row: RowAssiJusti = self.row_class(self, ligne._asdict()) row.ajouter_colonnes() self.add_row(row) - def paginer(self, query: Query) -> Pagination: + def paginer(self, collection: list, no_pagination: bool = False) -> Pagination: """ - Applique la pagination à une requête SQLAlchemy en fonction des paramètres de la classe. + Applique une pagination à une collection en fonction des paramètres de la classe. - Cette méthode prend une requête SQLAlchemy et applique la pagination en utilisant les + Cette méthode prend une collection et applique la pagination en utilisant les attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`. Args: - query (Query): La requête SQLAlchemy à paginer. Il s'agit d'une requête qui a déjà + collection (list): La collection à paginer. Il s'agit par exemple d'une requête qui a déjà été construite et qui est prête à être exécutée. Returns: Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée. Note: - Cette méthode ne modifie pas la requête originale; elle renvoie plutôt un nouvel + Cette méthode ne modifie pas la collection originelle; elle renvoie plutôt un nouvel objet qui contient les résultats paginés. """ - return query.paginate( - page=self.options.page, per_page=self.options.nb_ligne_page, error_out=False + return Pagination( + collection, + self.options.page, + -1 if no_pagination else self.options.nb_ligne_page, ) def joindre(self, query_assiduite: Query = None, query_justificatif: Query = None): @@ -172,7 +280,7 @@ class ListeAssiJusti(tb.Table): ] if self.options.show_desc: - assiduites_entities.append(Assiduite.description.label("description")) + assiduites_entities.append(Assiduite.description.label("desc")) query_assiduite = query_assiduite.with_entities(*assiduites_entities) queries.append(query_assiduite) @@ -194,7 +302,7 @@ class ListeAssiJusti(tb.Table): ] if self.options.show_desc: - justificatifs_entities.append(Justificatif.raison.label("description")) + justificatifs_entities.append(Justificatif.raison.label("desc")) query_justificatif = query_justificatif.with_entities( *justificatifs_entities @@ -210,7 +318,7 @@ class ListeAssiJusti(tb.Table): # Combiner les requêtes avec une union query_combinee = union(*queries).alias("combinee") - query_combinee = db.session.query(query_combinee).order_by(desc("date_debut")) + query_combinee = db.session.query(query_combinee) return query_combinee @@ -241,30 +349,46 @@ class RowAssiJusti(tb.Row): # Type d'objet self._type() - # Date de début - multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date() - # En excel, on export les "vraes dates". + # En excel, on export les "vraies dates". # En HTML, on écrit en français (on laisse les dates pour le tri) + + multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date() + + date_affichees: list[str] = [ + self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"), # date début + self.ligne["date_fin"].strftime("%d/%m/%y de %H:%M"), # date fin + ] + + if multi_days: + date_affichees[0] = self.ligne["date_debut"].strftime("%d/%m/%y") + date_affichees[1] = self.ligne["date_fin"].strftime("%d/%m/%y") + self.add_cell( "date_debut", "Date de début", - self.ligne["date_debut"].strftime("%d/%m/%y") - if multi_days - else self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"), + date_affichees[0], data={"order": self.ligne["date_debut"]}, raw_content=self.ligne["date_debut"], - column_classes={"date", "date-debut"}, + column_classes={ + "date", + "date-debut", + "external-sort", + "external-type:date_debut", + }, ) # Date de fin self.add_cell( "date_fin", "Date de fin", - self.ligne["date_fin"].strftime("%d/%m/%y") - if multi_days - else self.ligne["date_fin"].strftime("à %H:%M"), + date_affichees[1], raw_content=self.ligne["date_fin"], # Pour excel data={"order": self.ligne["date_fin"]}, - column_classes={"date", "date-fin"}, + column_classes={ + "date", + "date-fin", + "external-sort", + "external-type:date_fin", + }, ) # Ajout des colonnes optionnelles @@ -283,7 +407,11 @@ class RowAssiJusti(tb.Row): data={"order": self.ligne["entry_date"] or ""}, raw_content=self.ligne["entry_date"], classes=["small-font"], - column_classes={"entry_date"}, + column_classes={ + "entry_date", + "external-sort", + "external-type:entry_date", + }, ) def _type(self) -> None: @@ -349,10 +477,21 @@ class RowAssiJusti(tb.Row): def _optionnelles(self) -> None: if self.table.options.show_desc: + if self.ligne.get("type") == "justificatif": + # protection de la "raison" + if ( + self.ligne["user_id"] == current_user.id + or self.table.can_view_justif_detail + ): + description = self.ligne["desc"] if self.ligne["desc"] else "" + else: + description = "(cachée)" + else: + description = self.ligne["desc"] if self.ligne["desc"] else "" self.add_cell( "description", "Description", - self.ligne["description"] if self.ligne["description"] else "", + description, ) if self.table.options.show_module: if self.ligne["type"] == "assiduite": @@ -415,9 +554,13 @@ class RowAssiJusti(tb.Row): ) html.append(f'') # utiliser url_for - # Justifier (si type Assiduité et est_just faux) + # Justifier (si type Assiduité, etat != Présent et est_just faux) - if self.ligne["type"] == "assiduite" and not self.ligne["est_just"]: + if ( + self.ligne["type"] == "assiduite" + and self.ligne["etat"] != EtatAssiduite.PRESENT + and not self.ligne["est_just"] + ): url = url_for( "assiduites.tableau_assiduite_actions", type=self.ligne["type"], @@ -541,6 +684,7 @@ class AssiDisplayOptions: show_etu: str | bool = True, show_actions: str | bool = True, show_module: str | bool = False, + order: tuple[str, str | bool] = None, ): self.page: int = page self.nb_ligne_page: int = nb_ligne_page @@ -554,6 +698,10 @@ class AssiDisplayOptions: self.show_actions = to_bool(show_actions) self.show_module = to_bool(show_module) + self.order = ( + ("date_debut", False) if order is None else (order[0], to_bool(order[1])) + ) + def remplacer(self, **kwargs): "Positionne options booléennes selon arguments" for k, v in kwargs.items(): @@ -565,6 +713,12 @@ class AssiDisplayOptions: self.nb_ligne_page = min( self.nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE ) + elif k == "order": + setattr( + self, + k, + ("date_debut", False) if v is None else (v[0], to_bool(v[1])), + ) class AssiJustifData: diff --git a/app/tables/visu_assiduites.py b/app/tables/visu_assiduites.py index 0b7ee3fc..f9acbd83 100644 --- a/app/tables/visu_assiduites.py +++ b/app/tables/visu_assiduites.py @@ -129,41 +129,44 @@ class RowAssi(tb.Row): ) def _get_etud_stats(self, etud: Identite) -> dict[str, list[str, float, float]]: - # XXX TODO @iziram commentaire sur la fonction et la var. retour + """ + Renvoie le comptage (dans la métrique du département) des différents états d'assiduité d'un étudiant + + Returns : + { + "" : [, , ] + } + + """ + + # Préparation du retour retour: dict[str, tuple[str, float, float]] = { "absent": ["Absences", 0.0, 0.0], "retard": ["Retards", 0.0, 0.0], "present": ["Présences", 0.0, 0.0], } + # Récupération de la métrique du département assi_metric = scu.translate_assiduites_metric( sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id), ) + compte_etat: dict[str, dict] = scass.get_assiduites_stats( + assiduites=etud.assiduites, + metric=assi_metric, + filtered={ + "date_debut": self.dates[0], + "date_fin": self.dates[1], + "etat": "absent,present,retard", # pour tout compter d'un coup + "split": 1, # afin d'avoir la division des stats en état, etatjust, etatnonjust + }, + ) + + # Pour chaque état on met à jour les valeurs de retour for etat, valeur in retour.items(): - compte_etat = scass.get_assiduites_stats( - assiduites=etud.assiduites, - metric=assi_metric, - filtered={ - "date_debut": self.dates[0], - "date_fin": self.dates[1], - "etat": etat, - }, - ) - - compte_etat_just = scass.get_assiduites_stats( - assiduites=etud.assiduites, - metric=assi_metric, - filtered={ - "date_debut": self.dates[0], - "date_fin": self.dates[1], - "etat": etat, - "est_just": True, - }, - ) - - valeur[1] = compte_etat[assi_metric] - valeur[2] = compte_etat_just[assi_metric] + valeur[1] = compte_etat[etat][assi_metric] + if etat != "present": + valeur[2] = compte_etat[etat]["justifie"][assi_metric] return retour diff --git a/app/templates/about.j2 b/app/templates/about.j2 index b78172cf..4d960b1c 100644 --- a/app/templates/about.j2 +++ b/app/templates/about.j2 @@ -29,6 +29,15 @@

+
+

Coordonnées du délégué à la protection des données (DPO)

+{% if ScoDocSiteConfig.get("rgpd_coordonnees_dpo") %} + {{ ScoDocSiteConfig.get("rgpd_coordonnees_dpo") }} +{% else %} + non renseigné +{% endif %} +
+

Dernières évolutions

{{ news|safe }} diff --git a/app/templates/assiduites/pages/ajout_assiduite_etud.j2 b/app/templates/assiduites/pages/ajout_assiduite_etud.j2 index cdc3dc7c..7871bf90 100644 --- a/app/templates/assiduites/pages/ajout_assiduite_etud.j2 +++ b/app/templates/assiduites/pages/ajout_assiduite_etud.j2 @@ -6,7 +6,6 @@ {% block styles %} {{super()}} - {% endblock %} @@ -88,6 +87,13 @@ div.submit > input { {{ form.modimpl }} {{ render_field_errors(form, 'modimpl') }} + {# Justifiée #} +
+ {{ form.est_just.label }} : + {{ form.est_just }} + génère un justificatif valide ayant la même période que l'assiduité signalée + {{ render_field_errors(form, 'est_just') }} +
{# Description #}
{{ form.description.label }}
@@ -114,19 +120,7 @@ div.submit > input { {% block scripts %} {{ super() }} - - +{% include "sco_timepicker.j2" %} {% endblock scripts %} diff --git a/app/templates/assiduites/pages/ajout_assiduites.j2 b/app/templates/assiduites/pages/ajout_assiduites.j2 deleted file mode 100644 index 9326d930..00000000 --- a/app/templates/assiduites/pages/ajout_assiduites.j2 +++ /dev/null @@ -1,246 +0,0 @@ -{% include "assiduites/widgets/toast.j2" %} -{% include "assiduites/widgets/alert.j2" %} - -{% block pageContent %} -
-

Signaler une absence, présence ou retard pour {{etud.html_link_fiche()|safe}}

- {% if saisie_eval %} -
-
-

- La saisie a été préconfigurée en fonction de l'évaluation.
- Une fois la saisie terminée, cliquez sur le lien ci-dessous -

- retourner sur la page de l'évaluation -
- {% endif %} -
-
-
-
- Date de début - - - Journée entière -
-
- Date de fin - -
-
- -
-
- État de l'assiduité - -
-
-
-
- Module - {% with moduleid="ajout_assiduite_module_impl",label=false %} - {% include "assiduites/widgets/moduleimpl_dynamic_selector.j2" %} - {% endwith %} -
-
- -
-
- Raison - -
-
- -
- - -
- - -
- -
-
- {{tableau | safe }} -
- -
- - - -{% endblock pageContent %} diff --git a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 index 6848cf2a..3aee67d0 100644 --- a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 @@ -5,7 +5,6 @@ Si justif, edit #} {% block styles %} {{super()}} - {% endblock %} @@ -18,6 +17,9 @@ form#ajout-justificatif-etud { form#ajout-justificatif-etud > div { margin-bottom: 16px; } +fieldset > div { + margin-bottom: 12px; +} div.fichiers { margin-top: 16px; margin-bottom: 32px; @@ -34,9 +36,21 @@ div.submit { div.submit > input { margin-right: 16px; } +.info-saisie { + margin-top: 12px; + margin-bottom: 12px; + font-style: italic; +}
-

Justifier des absences ou retards

+

{{title|safe}}

+ + {% if justif %} +
+ Saisie par {{justif.user.get_prenomnom() if justif.user else "inconnu"}} + le {{justif.entry_date.strftime("%d/%m/%Y à %H:%M") if justif.entry_date else "?"}} +
+ {% endif %}
@@ -73,16 +87,24 @@ div.submit > input {
{# Raison #}
-
{{ form.raison.label }}
- {{ form.raison() }} - {{ render_field_errors(form, 'raison') }} + {% if (not justif) or can_view_justif_detail %} +
{{ form.raison.label }}
+ {{ form.raison() }} + {{ render_field_errors(form, 'raison') }} +
La raison sera visible aux utilisateurs ayant le droit + AbsJustifView et à celui ayant déposé le justificatif + {%- if justif %} ({{justif.user.get_prenomnom()}}){%- endif -%}. +
+ {% else %} +
raison confidentielle
+ {% endif %}
{# Liste des fichiers existants #} {% if justif and nb_files > 0 %}
{{nb_files}} fichiers justificatifs déposés {% if filenames|length < nb_files %} - , dont {{filenames|length}} vous sont accessibles + , dont {{filenames|length}} vous {{'sont accessibles' if filenames|length > 1 else 'est accessible'}} {% endif %}
@@ -105,6 +127,7 @@ div.submit > input { {{ form.entry_date.label }} : {{ form.entry_date }} laisser vide pour date courante {{ render_field_errors(form, 'entry_date') }} + {# Submit #}
{{ form.submit }} {{ form.cancel }} @@ -126,21 +149,9 @@ div.submit > input { {% block scripts %} {{ super() }} - - +{% include "sco_timepicker.j2" %} +{% endblock scripts %} +{% block app_content %} +

Traitement de l'assiduité

+

+Pour saisir l'assiduité ou consulter les états, il est recommandé de passer par +le semestre concerné (saisie par jour ou saisie différée). +

+

Pour signaler, annuler ou justifier l'assiduité d'un seul étudiant, + choisissez d'abord la personne concernée :

+
+{{search_etud | safe}} +
+{{billets | safe}} +
- -

Justificatifs en attente (ou modifiés)

- - - - - {% include "assiduites/widgets/tableau_justi.j2" %} + {{tableau | safe }}
- -
- Année scolaire 2022-2023 Changer année: - -
- -
-

Gestion des justificatifs

-

- Faites - clic droit sur une ligne du tableau pour afficher le menu contextuel : -

    -
  • Détails : Affiche les détails du justificatif sélectionné
  • -
  • Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)
  • -
  • Supprimer : Permet de supprimer le justificatif (Action Irréversible)
  • -
-

-
- - \ No newline at end of file +{% endblock app_content %} \ No newline at end of file diff --git a/app/templates/assiduites/pages/bilan_etud.j2 b/app/templates/assiduites/pages/bilan_etud.j2 index 02ba9591..15fed8aa 100644 --- a/app/templates/assiduites/pages/bilan_etud.j2 +++ b/app/templates/assiduites/pages/bilan_etud.j2 @@ -1,3 +1,70 @@ +{% extends "sco_page.j2" %} + +{% block title %} +Bilan assiduité de {{sco.etud.nomprenom}} +{% endblock title %} + +{% block styles %} + {{ super() }} + + +{% endblock styles %} + {% block app_content %} {% include "assiduites/widgets/tableau_base.j2" %}
@@ -12,9 +79,9 @@

Statistiques d'assiduité

- -
@@ -25,27 +92,7 @@
- -

Absences et retards non justifiés

- - {# XXX XXX XXX #} -
Attention, cette page utilise des couleurs et conventions différentes - de celles des autres pages ScoDoc: elle sera prochainement modifée, merci de votre patience. -
- - - - - - {% include "assiduites/widgets/tableau_assi.j2" %} - -

Justificatifs en attente (ou modifiés)

- - - - - {% include "assiduites/widgets/tableau_justi.j2" %} - + {{tableau | safe }}
@@ -60,36 +107,18 @@ département)

Les statistiques sont calculées entre les deux dates sélectionnées. Après modification des dates, appuyer sur le bouton "Actualiser"

-

Gestion des justificatifs

-

- Faites - clic droit sur une ligne du tableau pour afficher le menu - contextuel : -

-
    -
  • Détails : affiche les détails du justificatif sélectionné
  • -
  • Éditer : modifie le justificatif (dates, état, ajouter/supprimer fichier, etc.)
  • -
  • Supprimer : supprime le justificatif (action irréversible)
  • -
- -

Gestion de l'assiduité

-

- Faites - clic droit sur une ligne du tableau pour afficher le menu - contextuel : -

-
    -
  • Détails : affiche les détails de l'élément sélectionnée
  • -
  • Editer : modifie l'élément (module, état)
  • -
  • Supprimer : supprime l'élément (action irréversible)
  • -
{% endblock app_content %} - + + + - diff --git a/app/templates/assiduites/pages/calendrier_assi_etud.j2 b/app/templates/assiduites/pages/calendrier_assi_etud.j2 index 3eaedb52..f0478610 100644 --- a/app/templates/assiduites/pages/calendrier_assi_etud.j2 +++ b/app/templates/assiduites/pages/calendrier_assi_etud.j2 @@ -1,4 +1,14 @@ -{% block pageContent %} +{% extends "sco_page.j2" %} +{% block title %} +Calendrier de l'assiduité +{% endblock title %} +{% block styles %} + {{ super() }} + + +{% endblock styles %} + +{% block app_content %} {% include "assiduites/widgets/alert.j2" %}
@@ -250,219 +260,6 @@ } - - .day .dayline { - position: absolute; - display: none; - top: 100%; - z-index: 50; - width: max-content; - height: 75px; - background-color: #dedede; - border-radius: 15px; - padding: 5px; - } - - .day:hover .dayline { - display: block; - } - - .dayline .mini-timeline { - margin-top: 10%; - } - - .dayline-title { - margin: 0; - } - - .dayline .mini_tick { - position: absolute; - text-align: center; - top: 0; - transform: translateY(-110%); - z-index: 50; - } - - .dayline .mini_tick::after { - display: block; - content: "|"; - position: absolute; - bottom: -69%; - z-index: 2; - transform: translateX(200%); - } - - #label-nom, - #label-justi { - display: none; - } - - .demi .day { - display: flex; - justify-content: space-evenly; - } - - .demi .day>span { - display: block; - flex: 1; - text-align: center; - z-index: 1; - width: 100%; - border: 1px solid #d5d5d5; - position: relative; - } - - .demi .day>span:first-of-type { - width: 3em; - min-width: 3em; - } - - .options>* { - margin-right: 5px; - } - - .options input { - margin-right: 6px; - } - - .options label { - font-weight: normal; - margin-right: 16px; - } - - - /*Gestion des bubbles*/ - .assiduite-bubble { - position: relative; - display: none; - background-color: #f9f9f9; - border-radius: 5px; - padding: 8px; - border: 3px solid #ccc; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - font-size: 12px; - line-height: 1.4; - z-index: 500; - min-width: max-content; - top: 200%; - } - - .mini-timeline-block:hover .assiduite-bubble { - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - } - - .assiduite-bubble::before { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - border-width: 6px; - border-style: solid; - border-color: transparent transparent #f9f9f9 transparent; - } - - .assiduite-bubble::after { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - border-width: 5px; - border-style: solid; - border-color: transparent transparent #ccc transparent; - } - - .assiduite-id, - .assiduite-period, - .assiduite-state, - .assiduite-user_id { - margin-bottom: 4px; - } - - .assiduite-bubble.absent { - border-color: var(--color-absent) !important; - } - - .assiduite-bubble.present { - border-color: var(--color-present) !important; - } - - .assiduite-bubble.retard { - border-color: var(--color-retard) !important; - } - - /*Gestion des minitimelines*/ - .mini-timeline { - height: 7px; - border: 1px solid black; - position: relative; - background-color: white; - } - - .mini-timeline.single { - height: 9px; - } - - .mini-timeline-block { - position: absolute; - height: 100%; - z-index: 1; - display: flex; - justify-content: flex-start; - align-items: center; - flex-direction: column; - } - - .mini-timeline-block { - cursor: pointer; - } - - .mini_tick { - position: absolute; - text-align: start; - top: -40px; - transform: translateX(-50%); - z-index: 50; - - } - - .mini_tick::after { - display: block; - content: "|"; - position: absolute; - bottom: -2px; - z-index: 2; - } - - .mini-timeline-block.creneau { - outline: 3px solid var(--color-primary); - pointer-events: none; - } - - .mini-timeline-block.absent { - background-color: var(--color-absent) !important; - } - - .mini-timeline-block.present { - background-color: var(--color-present) !important; - } - - .mini-timeline-block.retard { - background-color: var(--color-retard) !important; - } - - .mini-timeline-block.justified { - background-image: var(--motif-justi); - } - - .mini-timeline-block.invalid_justified { - background-image: var(--motif-justi-invalide); - } - @media print { .couleurs.print { @@ -593,4 +390,4 @@ -{% endblock pageContent %} +{% endblock app_content %} diff --git a/app/templates/assiduites/pages/choix_date.j2 b/app/templates/assiduites/pages/choix_date.j2 index d743ac15..684dddd2 100644 --- a/app/templates/assiduites/pages/choix_date.j2 +++ b/app/templates/assiduites/pages/choix_date.j2 @@ -1,11 +1,6 @@ {% extends "sco_page.j2" %} {% import 'wtf.j2' as wtf %} -{% block styles %} - {{super()}} - -{% endblock %} - {% block app_content %} {% for err_msg in form.error_messages %}
@@ -24,7 +19,3 @@ {% endblock app_content %} -{% block scripts %} -{{ super() }} - -{% endblock scripts %} \ No newline at end of file diff --git a/app/templates/assiduites/pages/config_assiduites.j2 b/app/templates/assiduites/pages/config_assiduites.j2 index 1b71738e..b69f5a2e 100644 --- a/app/templates/assiduites/pages/config_assiduites.j2 +++ b/app/templates/assiduites/pages/config_assiduites.j2 @@ -3,6 +3,7 @@ {% block styles %} {{super()}} + - - {% endblock %} - -
\ No newline at end of file diff --git a/app/templates/assiduites/pages/signal_assiduites_group.j2 b/app/templates/assiduites/pages/signal_assiduites_group.j2 index 238dd25c..12380afa 100644 --- a/app/templates/assiduites/pages/signal_assiduites_group.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_group.j2 @@ -1,4 +1,86 @@ +{% extends "sco_page.j2" %} + +{% block title %} + {{title}} +{% endblock title %} + + +{% block scripts %} + {{ super() }} + + + + + + + + +{% endblock scripts %} + +{% block styles %} + {{ super() }} + + + + + +{% endblock styles %} + + +{% block app_content %} {% include "assiduites/widgets/toast.j2" %} + +{{ minitimeline|safe }} +
@@ -20,7 +102,7 @@
Groupes : {{grp|safe}}
Date : -
@@ -47,7 +129,6 @@ Faire la saisie {% endif %} -

Utilisez le bouton "Actualiser" si vous modifier la date ou le(s) groupe(s) sélectionné(s)

@@ -79,57 +160,6 @@ {% include "assiduites/widgets/prompt.j2" %} {% include "assiduites/widgets/conflict.j2" %} - -
\ No newline at end of file +{% endblock app_content %} diff --git a/app/templates/assiduites/pages/tableau_assiduite_actions.j2 b/app/templates/assiduites/pages/tableau_assiduite_actions.j2 index 903fceba..705aaec3 100644 --- a/app/templates/assiduites/pages/tableau_assiduite_actions.j2 +++ b/app/templates/assiduites/pages/tableau_assiduite_actions.j2 @@ -10,11 +10,11 @@ {% if action == "modifier" %} {% include "assiduites/widgets/tableau_actions/modifier.j2" %} -{% else%} +{% else %} {% include "assiduites/widgets/tableau_actions/details.j2" %} {% endif %} -{% if not current_user.has_permission(sco.Permission.AbsJustifView)%} +{% if not current_user.has_permission(sco.Permission.AbsJustifView) %}
Vous n'avez pas la permission d'ouvrir les fichiers justificatifs déposés par d'autres personnes. @@ -22,7 +22,7 @@ {% endif %} \ No newline at end of file + + \ No newline at end of file diff --git a/app/templates/assiduites/widgets/differee.j2 b/app/templates/assiduites/widgets/differee.j2 index 250545c2..aebf1b98 100644 --- a/app/templates/assiduites/widgets/differee.j2 +++ b/app/templates/assiduites/widgets/differee.j2 @@ -270,6 +270,10 @@ -webkit-box-sizing: border-box; border: 10px solid white; } + + .mini-form { + color: black; + } - - + \ No newline at end of file diff --git a/app/templates/assiduites/widgets/moduleimpl_dynamic_selector.j2 b/app/templates/assiduites/widgets/moduleimpl_dynamic_selector.j2 deleted file mode 100644 index 80712eed..00000000 --- a/app/templates/assiduites/widgets/moduleimpl_dynamic_selector.j2 +++ /dev/null @@ -1,156 +0,0 @@ -
- {% if label != false%} - - {% else %} - {% endif %} - {% if moduleid %} - - {% else %} - - {% endif %} - - -
- - - - - \ No newline at end of file diff --git a/app/templates/assiduites/widgets/moduleimpl_selector_multiple.j2 b/app/templates/assiduites/widgets/moduleimpl_selector_multiple.j2 new file mode 100644 index 00000000..db77984f --- /dev/null +++ b/app/templates/assiduites/widgets/moduleimpl_selector_multiple.j2 @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/app/templates/assiduites/widgets/simplemoduleimpl_select.j2 b/app/templates/assiduites/widgets/simplemoduleimpl_select.j2 index f41dce59..1ed0c0ec 100644 --- a/app/templates/assiduites/widgets/simplemoduleimpl_select.j2 +++ b/app/templates/assiduites/widgets/simplemoduleimpl_select.j2 @@ -1,10 +1,3 @@ -{% if scu.is_assiduites_module_forced(request.args.get('formsemestre_id', None))%} -{% else %} -{% endif %} -{% if moduleimpl_id == "autre" %} - -{% else %} - -{% endif %} \ No newline at end of file + diff --git a/app/templates/assiduites/widgets/tableau.j2 b/app/templates/assiduites/widgets/tableau.j2 index 49325bc5..39bbb2f4 100644 --- a/app/templates/assiduites/widgets/tableau.j2 +++ b/app/templates/assiduites/widgets/tableau.j2 @@ -1,6 +1,6 @@
{{ titre }}
-
+
{% if afficher_options != false %} @@ -17,33 +17,133 @@ {{scu.ICON_XLS|safe}}
{% endif %} - - - - - + {% for i in [25,50,100,1000] %} + {% if i == options.nb_ligne_page %} + + {% else %} + + {% endif %} {% endfor %}
+
+
+ + + {% if total_pages > 1 %} +
    +
  • + < +
  • + +
  • + 1 +
  • + + + + {% if options.page > 2 and (options.page - 1) - 1 > 1 %} +
  • ...
  • + {% endif %} + + + {% for i in range(options.page - 1, options.page + 2) %} + {% if i > 1 and i < total_pages %} +
  • + {{ i }} +
  • + {% endif %} + {% endfor %} + + + + {% if options.page < total_pages - 1 and total_pages - (options.page + 1 ) > 1 %} +
  • ...
  • + {% endif %} + + +
  • + {{ total_pages }} +
  • +
  • + > +
  • +
+ {% else %} + +
    +
  • 1
  • +
+ {% endif %} +
+ {{table.html() | safe}} +
+ + + {% if total_pages > 1 %} +
    +
  • + < +
  • + +
  • + 1 +
  • + + + + {% if options.page > 2 and (options.page - 1) - 1 > 1 %} +
  • ...
  • + {% endif %} + + + {% for i in range(options.page - 1, options.page + 2) %} + {% if i > 1 and i < total_pages %} +
  • + {{ i }} +
  • + {% endif %} + {% endfor %} + + + + {% if options.page < total_pages - 1 and total_pages - (options.page + 1 ) > 1 %} +
  • ...
  • + {% endif %} + + +
  • + {{ total_pages }} +
  • +
  • + > +
  • +
+ {% else %} + +
    +
  • 1
  • +
+ {% endif %} +
-{{table.html() | safe}} +
+ + + + diff --git a/app/templates/assiduites/widgets/tableau_actions/details.j2 b/app/templates/assiduites/widgets/tableau_actions/details.j2 index 5b47eebb..7665ee03 100644 --- a/app/templates/assiduites/widgets/tableau_actions/details.j2 +++ b/app/templates/assiduites/widgets/tableau_actions/details.j2 @@ -1,13 +1,36 @@ -

Détails {{type}}

+

Détails {{type}} concernant {{etud.html_link_fiche()|safe}}

+ +
-
- Étudiant{{etud.e}} concerné{{etud.e}}: {{etud.html_link_fiche()|safe}} + +
+ Saisie par {{objet.saisie_par}} le {{objet.entry_date}}
- Période : {{objet.date_debut}} au {{objet.date_fin}} + Période : du {{objet.date_debut}} au {{objet.date_fin}}
{% if type == "Assiduité" %} @@ -23,27 +46,27 @@ {% else %} État de l'assiduité : {% endif %} - {{objet.etat}} + {{objet.etat}}
{% if type == "Justificatif" %} -
Raison:
- {% if objet.raison != None %} -
{{objet.raison}}
+ Raison: + {% if can_view_justif_detail %} + {{objet.raison or " "}} + {% else %} + (cachée) + {% endif %} {% else %} -
/div> - {% endif %} - {% else %} -
Description:
+ Description: {% if objet.description != None %} -
{{objet.description}}
+ {{objet.description}} {% else %} -
+ {% endif %} - {% endif %} -
+ {% endif %} +
{# Affichage des justificatifs si assiduité justifiée #} @@ -54,7 +77,8 @@ Oui
{% for justi in objet.justification.justificatifs %} - Justificatif du {{justi.date_debut}} au {{justi.date_fin}} {% endfor %}
@@ -69,13 +93,15 @@
Assiduités concernées: {% if objet.justification.assiduites %} - + {% else %} Aucune {% endif %} @@ -84,27 +110,31 @@ {# Affichage des fichiers des justificatifs #} {% if type == "Justificatif"%} -
- Fichiers enregistrés: - {% if objet.justification.fichiers.total != 0 %} -
Total : {{objet.justification.fichiers.total}}
-
    - {% for filename in objet.justification.fichiers.filenames %} -
  • - {{filename}} -
  • - {% endfor %} - {% if not objet.justification.fichiers.filenames %} -
  • fichiers non visibles
  • +
    + Fichiers enregistrés: + {% if objet.justification.fichiers.total != 0 %} +
    Total : {{objet.justification.fichiers.total}}
    +
      + {% for filename in objet.justification.fichiers.filenames %} +
    • + {{filename}} +
    • + {% endfor %} + {% if not objet.justification.fichiers.filenames %} +
    • fichiers non visibles
    • + {% endif %} +
    + {% else %} + Aucun {% endif %} -
- {% else %} - Aucun +
+ {% if current_user.has_permission(sco.Permission.AbsChange) %} + {% endif %} -
{% endif %} - -
- Saisie par {{objet.saisie_par}} le {{objet.entry_date}} -
+
diff --git a/app/templates/assiduites/widgets/tableau_actions/modifier.j2 b/app/templates/assiduites/widgets/tableau_actions/modifier.j2 index 73dedb4e..4c877fde 100644 --- a/app/templates/assiduites/widgets/tableau_actions/modifier.j2 +++ b/app/templates/assiduites/widgets/tableau_actions/modifier.j2 @@ -1,5 +1,7 @@

Modifier {{objet_name}} de {{ etud.html_link_fiche() | safe }}

+{# XXX cette page ne semble plus utile ! remplacée par edit_justificatif_etud #} +
Actuellement noté{{etud.e}} en {{objet_name|lower()}} du {{objet.date_debut}} au {{objet.date_fin}}
@@ -39,8 +41,12 @@ Actuellement noté{{etud.e}} en {{objet_name|lower()}} du {{objet.date_de - Raison - + {% if current_user.has_permission(sco.Permission.AbsJustifView) %} + Raison + + {% else %} +
(raison non visible ni modifiable)
+ {% endif %} Fichiers diff --git a/app/templates/assiduites/widgets/tableau_assi.j2 b/app/templates/assiduites/widgets/tableau_assi.j2 deleted file mode 100644 index c273c6c6..00000000 --- a/app/templates/assiduites/widgets/tableau_assi.j2 +++ /dev/null @@ -1,465 +0,0 @@ - - - - - - - - - - - - -
-
- Début - -
-
-
- Fin - -
-
-
- État - -
-
-
- Module - -
-
-
- Justifiée - -
-
-
-
- - - - diff --git a/app/templates/assiduites/widgets/timeline.j2 b/app/templates/assiduites/widgets/timeline.j2 index 24338b0c..754fb2da 100644 --- a/app/templates/assiduites/widgets/timeline.j2 +++ b/app/templates/assiduites/widgets/timeline.j2 @@ -89,8 +89,7 @@ } - function timelineMainEvent(event, callback) { - const func_call = callback ? callback : () => { }; + function timelineMainEvent(event) { const startX = (event.clientX || event.changedTouches[0].clientX); @@ -152,7 +151,6 @@ updatePeriodTimeLabel(); }; const mouseUp = () => { - generateAllEtudRow(); snapHandlesToQuarters(); timelineContainer.removeEventListener("mousemove", onMouseMove); func_call(); @@ -172,9 +170,12 @@ } } + let func_call = () => { }; + function setupTimeLine(callback) { - timelineContainer.addEventListener("mousedown", (e) => { timelineMainEvent(e, callback) }); - timelineContainer.addEventListener("touchstart", (e) => { timelineMainEvent(e, callback) }); + func_call = callback; + timelineContainer.addEventListener("mousedown", (e) => { timelineMainEvent(e) }); + timelineContainer.addEventListener("touchstart", (e) => { timelineMainEvent(e) }); } function adjustPeriodPosition(newLeft, newWidth) { @@ -230,8 +231,8 @@ periodTimeLine.style.width = `${widthPercentage}%`; snapHandlesToQuarters(); - generateAllEtudRow(); updatePeriodTimeLabel() + func_call(); } function snapHandlesToQuarters() { @@ -270,7 +271,6 @@ if (heure_deb != '' && heure_fin != '') { heure_deb = fromTime(heure_deb); heure_fin = fromTime(heure_fin); - console.warn(heure_deb, heure_fin) setPeriodValues(heure_deb, heure_fin) } {% endif %} diff --git a/app/templates/babase.j2 b/app/templates/babase.j2 index 96591cc2..6394643c 100644 --- a/app/templates/babase.j2 +++ b/app/templates/babase.j2 @@ -1,3 +1,4 @@ +{# Base de toutes les pages ScoDoc #} {% block doc -%} @@ -25,7 +26,10 @@ {% block scripts %} - + + {%- endblock scripts %} {%- endblock body %} diff --git a/app/templates/base.j2 b/app/templates/base.j2 index 70462e26..a75325e5 100644 --- a/app/templates/base.j2 +++ b/app/templates/base.j2 @@ -1,4 +1,4 @@ -{# -*- mode: jinja-html -*- #} +{# base des pages hors départements (accueil, configuration, ...) #} {% extends 'babase.j2' %} {% block styles %} @@ -103,6 +103,5 @@ {% endblock %} \ No newline at end of file diff --git a/app/templates/bul_head.j2 b/app/templates/bul_head.j2 index 8c725f01..0635c12c 100644 --- a/app/templates/bul_head.j2 +++ b/app/templates/bul_head.j2 @@ -7,7 +7,7 @@ {% if not is_apc %}

{{etud.nomprenom}}

{% endif %}
@@ -81,7 +81,7 @@
{% if not is_apc %} {% endif %} diff --git a/app/templates/but/bulletin_court_page.j2 b/app/templates/but/bulletin_court_page.j2 index 8f7aa446..a878e9f1 100644 --- a/app/templates/but/bulletin_court_page.j2 +++ b/app/templates/but/bulletin_court_page.j2 @@ -20,7 +20,7 @@ {{ue}} {% endfor %} - + {% for mod in bul[mod_type] %} @@ -154,8 +154,8 @@
diff --git a/app/templates/config_rgpd.j2 b/app/templates/config_rgpd.j2 new file mode 100644 index 00000000..f02c674e --- /dev/null +++ b/app/templates/config_rgpd.j2 @@ -0,0 +1,24 @@ +{% extends "base.j2" %} +{% import 'wtf.j2' as wtf %} + +{% block app_content %} +

{{title}}

+ +
+

Certaines fonctionnalités de ScoDoc vous aident à vous conformer + au règlement général de protection des données (RGPD) européen. +

+

Rappelons que le logiciel ScoDoc est fourni sans aucune garantie, + selon les termes de sa licence GNU GPL et que ni ses auteurs ni + l'association ScoDoc ne sauraient être tenus responsables de l'usage + qui en est fait. +

+
+ +
+
+ {{ wtf.quick_form(form) }} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/configuration.j2 b/app/templates/configuration.j2 index 604310fc..dfe87597 100644 --- a/app/templates/configuration.j2 +++ b/app/templates/configuration.j2 @@ -97,6 +97,12 @@ Heure: {{ time.strftime("%d/%m/%Y %H:%M") }}
+

Protection des données et RGPD

+
+ +
+ {% endblock %} {% block scripts %} diff --git a/app/templates/entreprises/fiche_entreprise.j2 b/app/templates/entreprises/fiche_entreprise.j2 index e2c0da71..08bf7ff9 100644 --- a/app/templates/entreprises/fiche_entreprise.j2 +++ b/app/templates/entreprises/fiche_entreprise.j2 @@ -183,7 +183,7 @@ {{ (stage_apprentissage.date_fin-stage_apprentissage.date_debut).days//7 }} semaines {{ stage_apprentissage.type_offre }} {{ + href="{{ url_for('scolar.fiche_etud', scodoc_dept=etudiant.dept_id|get_dept_acronym, etudid=stage_apprentissage.etudid) }}">{{ etudiant.nom|format_nom }} {{ etudiant.prenom|format_prenom }} {% if stage_apprentissage.formation_text %}{{ stage_apprentissage.formation_text }}{% endif %} {{ stage_apprentissage.notes }} diff --git a/app/templates/formsemestre/edt.j2 b/app/templates/formsemestre/edt.j2 index 7a72890a..b0a77f73 100644 --- a/app/templates/formsemestre/edt.j2 +++ b/app/templates/formsemestre/edt.j2 @@ -126,7 +126,7 @@ document.addEventListener('DOMContentLoaded', function() { time: function(event) { const date_start = new Date(event.start); const start = hm_formatter.format(date_start); - return `${start} ${event.title}`; + return `${start} ${event.title}
${event.raw}
`; }, }, timezone: { @@ -249,10 +249,8 @@ document.addEventListener('DOMContentLoaded', function() { if ((iso_date_start > "{{ formsemestre.date_fin.isoformat() }}") || (iso_date_end < "{{ formsemestre.date_debut.isoformat() }}")) { cal_warning.style.display = 'inline-block'; - console.log("OUTSIDE"); } else { cal_warning.style.display = 'none'; - console.log("INSIDE"); } } // View menu diff --git a/app/templates/sco_page.j2 b/app/templates/sco_page.j2 index 67ab61b3..f50bd4cb 100644 --- a/app/templates/sco_page.j2 +++ b/app/templates/sco_page.j2 @@ -1,10 +1,12 @@ -{# -*- mode: jinja-html -*- #} +{# -*- Base des pages ordinaires, dans départements -*- #} {% extends 'babase.j2' %} {% block styles %} {{super()}} + @@ -45,6 +47,7 @@ + diff --git a/app/templates/sco_timepicker.j2 b/app/templates/sco_timepicker.j2 new file mode 100644 index 00000000..de0e0123 --- /dev/null +++ b/app/templates/sco_timepicker.j2 @@ -0,0 +1,12 @@ + diff --git a/app/templates/scolar/partition_editor.j2 b/app/templates/scolar/partition_editor.j2 index ef0e5345..6d5bcf72 100644 --- a/app/templates/scolar/partition_editor.j2 +++ b/app/templates/scolar/partition_editor.j2 @@ -165,7 +165,7 @@ span.calendarEdit { etudiants.forEach(etudiant => { output += `
- + ${(() => { let output = "
"; arrayPartitions.forEach((partition) => { diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 865208c1..884931ad 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -51,7 +51,7 @@
{% if sco.etud %}

+ 'scolar.fiche_etud', scodoc_dept=g.scodoc_dept, etudid=sco.etud.id )}}" class="sidebar"> {{sco.etud.nomprenom}}

Absences diff --git a/app/templates/sidebar_dept.j2 b/app/templates/sidebar_dept.j2 index d8458f51..34ccda81 100644 --- a/app/templates/sidebar_dept.j2 +++ b/app/templates/sidebar_dept.j2 @@ -1,6 +1,6 @@ {# -*- mode: jinja-html -*- #} -

Dépt. {{ prefs["DeptName"] }}

{% if prefs["DeptIntranetURL"] %} @@ -8,8 +8,3 @@ {{ prefs["DeptIntranetTitle"] }} {% endif %}
- -{# -# Entreprises pas encore supporté en ScoDoc8 -#
Entreprises
-#} \ No newline at end of file diff --git a/app/views/__init__.py b/app/views/__init__.py index 5eed7802..890fb63e 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -14,6 +14,7 @@ from app.scodoc import notesdb as ndb from app.scodoc import sco_assiduites from app.scodoc import sco_formsemestre_status from app.scodoc import sco_preferences +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_permissions import Permission from app.scodoc import sco_utils as scu import sco_version @@ -90,9 +91,7 @@ class ScoData: self.etud = None # --- Informations sur semestre courant, si sélectionné if formsemestre is None: - formsemestre_id = ( - sco_formsemestre_status.retreive_formsemestre_from_request() - ) + formsemestre_id = retreive_formsemestre_from_request() if formsemestre_id is not None: formsemestre = FormSemestre.get_formsemestre(formsemestre_id) if formsemestre is None: diff --git a/app/views/absences.py b/app/views/absences.py index 408605bc..75e61f67 100644 --- a/app/views/absences.py +++ b/app/views/absences.py @@ -306,6 +306,7 @@ def _ProcessBilletAbsence( return: nombre de demi-journées d'absence ajoutées, -1 si billet déjà traité. NB: actuellement, les heures ne sont utilisées que pour déterminer si matin et/ou après-midi. + TODO: Vérifier l'intégration avec le module Assiduité """ if billet.etat: log(f"billet deja traite: {billet} !") @@ -316,7 +317,7 @@ def _ProcessBilletAbsence( datedebut = billet.abs_begin datefin = billet.abs_end log(f"Gestion du billet n°{billet.id}") - n = scass.create_absence( + n = scass.create_absence_billet( date_debut=datedebut, date_fin=datefin, etudid=billet.etudid, @@ -357,7 +358,7 @@ def process_billet_absence_form(billet_id: int): page_title=f"Traitement billet d'absence de {etud.nomprenom}", ), f"""

Traitement du billet {billet.id} : {etud.nomprenom}

""", ] diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 9b064d86..bd6a1b12 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -31,6 +31,7 @@ from typing import Any from flask import g, request, render_template, flash from flask import abort, url_for, redirect, Response from flask_login import current_user +from flask_sqlalchemy.query import Query from app import db, log from app.comp import res_sem @@ -81,89 +82,9 @@ from app.scodoc.sco_exceptions import ScoValueError from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids from app.scodoc.sco_archives_justificatifs import JustificatifArchiver -from flask_sqlalchemy.query import Query CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS -# --- UTILS --- - - -class HTMLElement: - """Représentation d'un HTMLElement version Python""" - - def __init__(self, tag: str, *attr, **kattr) -> None: - self.tag: str = tag - self.children: list["HTMLElement"] = [] - self.self_close: bool = kattr.get("self_close", False) - self.text_content: str = kattr.get("text_content", "") - self.key_attributes: dict[str, Any] = kattr - self.attributes: list[str] = list(attr) - - def add(self, *child: "HTMLElement") -> None: - """add child element to self""" - for kid in child: - self.children.append(kid) - - def remove(self, child: "HTMLElement") -> None: - """Remove child element from self""" - if child in self.children: - self.children.remove(child) - - def __str__(self) -> str: - attr: list[str] = self.attributes - - for att, val in self.key_attributes.items(): - if att in ("self_close", "text_content"): - continue - - if att != "cls": - attr.append(f'{att}="{val}"') - else: - attr.append(f'class="{val}"') - - if not self.self_close: - head: str = f"<{self.tag} {' '.join(attr)}>{self.text_content}" - body: str = "\n".join(map(str, self.children)) - foot: str = f"" - return head + body + foot - return f"<{self.tag} {' '.join(attr)}/>" - - def __add__(self, other: str): - return str(self) + other - - def __radd__(self, other: str): - return other + str(self) - - -class HTMLStringElement(HTMLElement): - """Utilisation d'une chaine de caracètres pour représenter un element""" - - def __init__(self, text: str) -> None: - self.text: str = text - HTMLElement.__init__(self, "textnode") - - def __str__(self) -> str: - return self.text - - -class HTMLBuilder: - def __init__(self, *content: HTMLElement | str) -> None: - self.content: list[HTMLElement | str] = list(content) - - def add(self, *element: HTMLElement | str): - self.content.extend(element) - - def remove(self, element: HTMLElement | str): - if element in self.content: - self.content.remove(element) - - def __str__(self) -> str: - return "\n".join(map(str, self.content)) - - def build(self) -> str: - return self.__str__() - - # -------------------------------------------------------------------- # # Assiduité (/ScoDoc//Scolarite/Assiduites/...) @@ -178,62 +99,25 @@ class HTMLBuilder: def bilan_dept(): """Gestionnaire assiduités, page principale""" - # Préparation de la page - H = [ - html_sco_header.sco_header( - page_title="Saisie de l'assiduité", - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=[ - "css/assiduites.css", - ], - ), - """

Traitement de l'assiduité

-

- Pour saisir l'assiduité ou consulter les états, il est recommandé de passer par - le semestre concerné (saisie par jour ou saisie différée). -

- """, - ] - H.append( - """

Pour signaler, annuler ou justifier l'assiduité d'un seul étudiant, - choisissez d'abord la personne concernée :

""" - ) - # Ajout de la barre de recherche d'étudiant (redirection vers bilan etud) - H.append(sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud")) - # Gestion des billets d'absences if current_user.has_permission( Permission.AbsChange ) and sco_preferences.get_preference("handle_billets_abs"): - H.append( - f""" + billets = f"""

Billets d'absence

""" - ) - - # Récupération des années d'étude du département - # (afin de sélectionner une année) + else: + billets = "" + # Récupération du département dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first() - annees: list[int] = sorted( - [f.date_debut.year for f in dept.formsemestres], - reverse=True, - ) - annee = scu.annee_scolaire() # Année courante, sera utilisée par défaut - # Génération d'une liste "json" d'années - annees_str: str = "[" - for ann in annees: - annees_str += f"{ann}," - annees_str += "]" # Récupération d'un formsemestre - # (pour n'afficher que les assiduites/justificatifs liés au formsemestre) + # (pour n'afficher que les justificatifs liés au formsemestre) formsemestre_id = request.args.get("formsemestre_id", "") + formsemestre = None if formsemestre_id: try: formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) @@ -241,19 +125,71 @@ def bilan_dept(): except AttributeError: formsemestre_id = "" - # Peuplement du template jinja - H.append( - render_template( - "assiduites/pages/bilan_dept.j2", - dept_id=g.scodoc_dept_id, - annee=annee, - annees=annees_str, - formsemestre_id=formsemestre_id, - group_id=request.args.get("group_id", ""), + # <=> Génération du tableau <=> + + # Récupération des étudiants du département / groupe + etudids: list[int] = [etud.id for etud in dept.etudiants] # cas département + group_ids = request.args.get("group_ids", "") + if group_ids and formsemestre: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids.split(","), + formsemestre_id=formsemestre.id, + select_all_when_unspecified=True, + ) + + if groups_infos.members: + etudids = [m["etudid"] for m in groups_infos.members] + + # justificatifs (en attente ou modifiés avec les semestres associés) + justificatifs_query: Query = Justificatif.query.filter( + Justificatif.etat.in_( + [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE] ), + Justificatif.etudid.in_(etudids), + ) + # Filtrage par semestre si formsemestre_id != "" + if formsemestre: + justificatifs_query = justificatifs_query.filter( + Justificatif.date_debut >= formsemestre.date_debut, + Justificatif.date_debut <= formsemestre.date_fin, + ) + + data = liste_assi.AssiJustifData( + assiduites_query=None, + justificatifs_query=justificatifs_query, + ) + + fname: str = "Bilan Département" + cache_key: str = "tableau-dept" + titre: str = "Justificatifs en attente ou modifiés" + + if formsemestre: + fname += f" {formsemestre.titre_annee()}" + cache_key += f"-{formsemestre.id}" + titre += f" {formsemestre.titre_annee()}" + + if group_ids: + cache_key += f" {group_ids}" + + table = _prepare_tableau( + data, + afficher_etu=True, + filename=fname, + titre=titre, + cache_key=cache_key, + ) + + if not table[0]: + return table[1] + + # Peuplement du template jinja + return render_template( + "assiduites/pages/bilan_dept.j2", + tableau=table[1], + search_etud=sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"), + billets=billets, + sco=ScoData(formsemestre=formsemestre), ) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) @bp.route("/ajout_assiduite_etud", methods=["GET", "POST"]) @@ -324,6 +260,7 @@ def ajout_assiduite_etud() -> str | Response: afficher_etu=False, filtre=liste_assi.AssiFiltre(type_obj=1), options=liste_assi.AssiDisplayOptions(show_module=True), + cache_key=f"tableau-etud-{etud.id}", ) if not is_html: return tableau @@ -461,11 +398,13 @@ def _record_assiduite_etud( case _: moduleimpl = ModuleImpl.query.get(moduleimpl_id) try: + assi_etat: scu.EtatAssiduite = scu.EtatAssiduite.get(form.assi_etat.data) + ass = Assiduite.create_assiduite( etud, dt_debut_tz_server, dt_fin_tz_server, - scu.EtatAssiduite.get(form.assi_etat.data), + assi_etat, description=form.description.data, entry_date=dt_entry_date_tz_server, external_data=external_data, @@ -476,6 +415,19 @@ def _record_assiduite_etud( db.session.add(ass) db.session.commit() + if assi_etat != scu.EtatAssiduite.PRESENT and form.est_just.data: + # si la case "justifiée est cochée alors on créé un justificatif de même période" + justi: Justificatif = Justificatif.create_justificatif( + etudiant=etud, + date_debut=dt_debut_tz_server, + date_fin=dt_fin_tz_server, + etat=scu.EtatJustificatif.VALIDE, + user_id=current_user.id, + ) + + # On met à jour les assiduités en fonction du nouveau justificatif + compute_assiduites_justified(etud.id, [justi]) + # Invalider cache scass.simple_invalidate_cache(ass.to_dict(), etud.id) @@ -508,39 +460,26 @@ def liste_assiduites_etud(): assiduite_id: int = request.args.get("assiduite_id", -1) # Préparation de la page - header: str = html_sco_header.sco_header( - page_title=f"Assiduité de {etud.nomprenom}", - init_qtip=True, - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) tableau = _prepare_tableau( liste_assi.AssiJustifData.from_etudiants( etud, ), - filename=f"assiduites-justificatifs-{etudid}", + filename=f"assiduites-justificatifs-{etud.id}", afficher_etu=False, filtre=liste_assi.AssiFiltre(type_obj=0), options=liste_assi.AssiDisplayOptions(show_module=True), + cache_key=f"tableau-etud-{etud.id}", ) if not tableau[0]: return tableau[1] - # Peuplement du template jinja - return HTMLBuilder( - header, - render_template( - "assiduites/pages/liste_assiduites.j2", - sco=ScoData(etud), - assi_id=assiduite_id, - tableau=tableau[1], - ), - ).build() + # Page HTML: + return render_template( + "assiduites/pages/liste_assiduites.j2", + assi_id=assiduite_id, + etud=etud, + tableau=tableau[1], + sco=ScoData(etud), + ) @bp.route("/bilan_etud") @@ -561,20 +500,6 @@ def bilan_etud(): if etud.dept_id != g.scodoc_dept_id: abort(404, "étudiant inexistant dans ce département") - # Préparation de la page (header) - header: str = html_sco_header.sco_header( - page_title=f"Bilan de l'assiduité de {etud.nomprenom}", - init_qtip=True, - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) - # Gestion des dates du bilan (par défaut l'année scolaire) date_debut = scu.date_debut_annee_scolaire().strftime("%d/%m/%Y") date_fin: str = scu.date_fin_annee_scolaire().strftime("%d/%m/%Y") @@ -584,22 +509,43 @@ def bilan_etud(): sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id), ) - # Génération de la page - return HTMLBuilder( - header, - render_template( - "assiduites/pages/bilan_etud.j2", - sco=ScoData(etud), - date_debut=date_debut, - date_fin=date_fin, - assi_metric=assi_metric, - assi_seuil=_get_seuil(), - assi_limit_annee=sco_preferences.get_preference( - "assi_limit_annee", - dept_id=g.scodoc_dept_id, - ), + # Récupération des assiduités et justificatifs de l'étudiant + data = liste_assi.AssiJustifData( + etud.assiduites.filter( + Assiduite.etat != scu.EtatAssiduite.PRESENT, Assiduite.est_just == False ), - ).build() + etud.justificatifs.filter( + Justificatif.etat.in_( + [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE] + ) + ), + ) + + table = _prepare_tableau( + data, + afficher_etu=False, + filename=f"Bilan assiduité {etud.nomprenom}", + titre="Bilan de l'assiduité de l'étudiant", + cache_key=f"tableau-etud-{etud.id}-bilan", + ) + + if not table[0]: + return table[1] + + # Génération de la page HTML + return render_template( + "assiduites/pages/bilan_etud.j2", + assi_limit_annee=sco_preferences.get_preference( + "assi_limit_annee", + dept_id=g.scodoc_dept_id, + ), + assi_metric=assi_metric, + assi_seuil=_get_seuil(), + date_debut=date_debut, + date_fin=date_fin, + sco=ScoData(etud), + tableau=table[1], + ) @bp.route("/edit_justificatif_etud/", methods=["GET", "POST"]) @@ -607,7 +553,9 @@ def bilan_etud(): @permission_required(Permission.AbsChange) def edit_justificatif_etud(justif_id: int): """ - Edition d'un justificatif + Edition d'un justificatif. + Il faut de plus la permission pour voir/modifier la raison. + Args: justif_id (int): l'identifiant du justificatif @@ -648,21 +596,21 @@ def edit_justificatif_etud(justif_id: int): "assi_limit_annee", dept_id=g.scodoc_dept_id, ), + can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView) + or current_user.id == justif.user_id, etud=justif.etudiant, filenames=filenames, form=form, justif=justif, nb_files=nb_files, - page_title="Modification justificatif", + title=f"Modification justificatif absence de {justif.etudiant.html_link_fiche()}", redirect_url=redirect_url, sco=ScoData(justif.etudiant), scu=scu, ) -@bp.route( - "/ajout_justificatif_etud", methods=["GET", "POST"] -) # was AjoutJustificatifEtud +@bp.route("/ajout_justificatif_etud", methods=["GET", "POST"]) @scodoc @permission_required(Permission.AbsChange) def ajout_justificatif_etud(): @@ -697,6 +645,7 @@ def ajout_justificatif_etud(): options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True), afficher_options=False, titre="Justificatifs enregistrés pour cet étudiant", + cache_key=f"tableau-etud-{etud.id}", ) if not is_html: return tableau @@ -709,7 +658,7 @@ def ajout_justificatif_etud(): ), etud=etud, form=form, - page_title="Justificatifs", + title=f"Ajout justificatif absence pour {etud.html_link_fiche()}", redirect_url=redirect_url, sco=ScoData(etud), scu=scu, @@ -777,7 +726,12 @@ def _record_justificatif_etud( db.session.rollback() return False db.session.commit() - compute_assiduites_justified(etud.id, [justif]) + # FIX TEMPORAIRE: + # on reprend toutes les assiduités et tous les justificatifs + # pour utiliser le "reset" (remise en "non_just") des assiduités + # (à terme, il faudrait ne recalculer que les assiduités impactées) + # VOIR TODO dans compute_assiduites_justified + compute_assiduites_justified(etud.id, reset=True) scass.simple_invalidate_cache(justif.to_dict(), etud.id) flash(message) return True @@ -860,36 +814,20 @@ def calendrier_assi_etud(): annees_str += f"{ann}," annees_str += "]" - # Préparation de la page - header: str = html_sco_header.sco_header( - page_title="Calendrier de l'assiduité", - init_qtip=True, - javascripts=[ - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) + calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee) - calendrier = generate_calendar(etud, annee) # Peuplement du template jinja - return HTMLBuilder( - header, - render_template( - "assiduites/pages/calendrier_assi_etud.j2", - sco=ScoData(etud), - annee=annee, - nonworkdays=_non_work_days(), - annees=annees_str, - calendrier=calendrier, - mode_demi=mode_demi, - show_pres=show_pres, - show_reta=show_reta, - ), - ).build() + return render_template( + "assiduites/pages/calendrier_assi_etud.j2", + sco=ScoData(etud), + annee=annee, + nonworkdays=_non_work_days(), + annees=annees_str, + calendrier=calendrier, + mode_demi=mode_demi, + show_pres=show_pres, + show_reta=show_reta, + ) @bp.route("/choix_date", methods=["GET", "POST"]) @@ -924,7 +862,9 @@ def choix_date() -> str: if ok: return redirect( url_for( - "assiduites.signal_assiduites_group", + "assiduites.signal_assiduites_group" + if request.args.get("readonly") is None + else "assiduites.visu_assiduites_group", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, group_ids=group_ids, @@ -947,12 +887,13 @@ def choix_date() -> str: @permission_required(Permission.AbsChange) def signal_assiduites_group(): """ - signal_assiduites_group Saisie des assiduités des groupes pour le jour donnée + signal_assiduites_group Saisie des assiduités des groupes pour le jour donné Returns: str: l'html généré """ # Récupération des paramètres de l'url + # formsemestre_id est optionnel si modimpl est indiqué 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()) @@ -972,13 +913,20 @@ def signal_assiduites_group(): moduleimpl_id = int(moduleimpl_id) except (TypeError, ValueError): moduleimpl_id = None - + if moduleimpl_id is not None and moduleimpl_id >= 0: + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) + else: + modimpl = None # Vérification du formsemestre_id try: formsemestre_id = int(formsemestre_id) except (TypeError, ValueError): formsemestre_id = None + if (formsemestre_id < 0 or formsemestre_id is None) and modimpl: + # si le module est spécifié mais pas le semestre: + formsemestre_id = modimpl.formsemestre_id + # Gestion des groupes groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids, @@ -1052,54 +1000,33 @@ def signal_assiduites_group(): grp + ' ' + groups_infos.groups_titles + "" ) - # --- Génération de l'HTML --- - - header: str = html_sco_header.sco_header( - page_title="Saisie journalière des assiduités", - init_qtip=True, - javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS - + [ - # Voir fonctionnement JS - "js/etud_info.js", - "js/groups_view.js", - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) - # Récupération du semestre en dictionnaire sem = formsemestre.to_dict() - # Peuplement du template jinja - return HTMLBuilder( - header, - _mini_timeline(), - render_template( - "assiduites/pages/signal_assiduites_group.j2", - gr_tit=gr_tit, - sem=sem["titre_num"], - date=_dateiso_to_datefr(date), + # Page HTML + return render_template( + "assiduites/pages/signal_assiduites_group.j2", + date=_dateiso_to_datefr(date), + defdem=_get_etuds_dem_def(formsemestre), + forcer_module=sco_preferences.get_preference( + "forcer_module", formsemestre_id=formsemestre_id, - grp=sco_groups_view.menu_groups_choice(groups_infos), - moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), - 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), - forcer_module=sco_preferences.get_preference( - "forcer_module", - formsemestre_id=formsemestre_id, - dept_id=g.scodoc_dept_id, - ), - defdem=_get_etuds_dem_def(formsemestre), - readonly="false", + dept_id=g.scodoc_dept_id, ), - html_sco_header.sco_footer(), - ).build() + formsemestre_date_debut=str(formsemestre.date_debut), + formsemestre_date_fin=str(formsemestre.date_fin), + formsemestre_id=formsemestre_id, + gr_tit=gr_tit, + grp=sco_groups_view.menu_groups_choice(groups_infos), + minitimeline=_mini_timeline(), + moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), + nonworkdays=_non_work_days(), + readonly="false", + sco=ScoData(formsemestre=formsemestre), + sem=sem["titre_num"], + timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])), + title="Saisie journalière des assiduités", + ) @bp.route("/visu_assiduites_group") @@ -1165,13 +1092,19 @@ def visu_assiduites_group(): ] # --- Vérification de la date --- - real_date = scu.is_iso_formated(date, True).date() - - if real_date < formsemestre.date_debut: - date = formsemestre.date_debut.isoformat() - elif real_date > formsemestre.date_fin: - date = formsemestre.date_fin.isoformat() + if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin: + # Si le jour est hors semestre, renvoyer vers choix date + return redirect( + url_for( + "assiduites.choix_date", + formsemestre_id=formsemestre_id, + group_ids=group_ids, + moduleimpl_id=moduleimpl_id, + scodoc_dept=g.scodoc_dept, + readonly="true", + ) + ) # --- Restriction en fonction du moduleimpl_id --- if moduleimpl_id: @@ -1188,7 +1121,7 @@ def visu_assiduites_group(): # Si aucun etudiant n'est inscrit au module choisi... moduleimpl_id = None - # --- Génération de l'HTML --- + # --- Génération du HTML --- if groups_infos.tous_les_etuds_du_sem: gr_tit = "en" @@ -1201,51 +1134,32 @@ def visu_assiduites_group(): grp + ' ' + groups_infos.groups_titles + "" ) - header: str = html_sco_header.sco_header( - page_title="Saisie journalière de l'assiduité", - init_qtip=True, - javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS - + [ - # Voir fonctionnement JS - "js/etud_info.js", - "js/groups_view.js", - "js/assiduites.js", - "js/date_utils.js", - ], - cssstyles=CSSSTYLES - + [ - "css/assiduites.css", - ], - ) - # Récupération du semestre en dictionnaire sem = formsemestre.to_dict() - return HTMLBuilder( - header, - _mini_timeline(), - render_template( - "assiduites/pages/signal_assiduites_group.j2", - gr_tit=gr_tit, - sem=sem["titre_num"], - date=_dateiso_to_datefr(date), + return render_template( + "assiduites/pages/signal_assiduites_group.j2", + date=_dateiso_to_datefr(date), + defdem=_get_etuds_dem_def(formsemestre), + forcer_module=sco_preferences.get_preference( + "forcer_module", formsemestre_id=formsemestre_id, - grp=sco_groups_view.menu_groups_choice(groups_infos), - moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), - timeline=_timeline(), - nonworkdays=_non_work_days(), - formsemestre_date_debut=str(formsemestre.date_debut), - formsemestre_date_fin=str(formsemestre.date_fin), - forcer_module=sco_preferences.get_preference( - "forcer_module", - formsemestre_id=formsemestre_id, - dept_id=g.scodoc_dept_id, - ), - defdem=_get_etuds_dem_def(formsemestre), - readonly="true", + dept_id=g.scodoc_dept_id, ), - html_sco_header.sco_footer(), - ).build() + formsemestre_date_debut=str(formsemestre.date_debut), + formsemestre_date_fin=str(formsemestre.date_fin), + formsemestre_id=formsemestre_id, + gr_tit=gr_tit, + grp=sco_groups_view.menu_groups_choice(groups_infos), + minitimeline=_mini_timeline(), + moduleimpl_select=_module_selector(formsemestre, moduleimpl_id), + nonworkdays=_non_work_days(), + sem=sem["titre_num"], + timeline=_timeline(), + readonly="true", + sco=ScoData(formsemestre=formsemestre), + title="Saisie journalière de l'assiduité", + ) class RowEtudWithAssi(RowEtud): @@ -1256,11 +1170,13 @@ class RowEtudWithAssi(RowEtud): table: TableEtud, etud: Identite, etat_assiduite: str, + est_just: bool, *args, **kwargs, ): super().__init__(table, etud, *args, **kwargs) self.etat_assiduite = etat_assiduite + self.est_just = est_just # remplace lien vers fiche par lien vers calendrier self.target_url = url_for( "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id @@ -1278,6 +1194,9 @@ class RowEtudWithAssi(RowEtud): ) self.classes += ["row-assiduite", self.etat_assiduite.lower()] + if self.est_just: + self.classes += ["justifiee"] + @bp.route("/etat_abs_date") @scodoc @@ -1325,9 +1244,7 @@ def etat_abs_date(): Assiduite.etudid.in_([etud.id for etud in etuds]) ) # Filtrage des assiduités en fonction des dates données - assiduites = scass.filter_by_date( - assiduites, Assiduite, date_debut, date_fin, False - ) + assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin) # Génération table table = TableEtud(row_class=RowEtudWithAssi) @@ -1335,11 +1252,12 @@ def etat_abs_date(): # On récupère l'état de la première assiduité sur la période assi = assiduites.filter_by(etudid=etud.id).first() etat = "" - if assi is not None and assi.etat != scu.EtatAssiduite.PRESENT: - etat = scu.EtatAssiduite.inverse().get(assi.etat).name - row = table.row_class(table, etud, etat) - row.add_etud_cols() - table.add_row(row) + if assi is not None: + if assi.etat != scu.EtatAssiduite.PRESENT: + etat = scu.EtatAssiduite.inverse().get(assi.etat).name + row = table.row_class(table, etud, etat, assi.est_just) + row.add_etud_cols() + table.add_row(row) if fmt.startswith("xls"): return scu.send_file( @@ -1442,6 +1360,7 @@ def _prepare_tableau( options: liste_assi.AssiDisplayOptions = None, afficher_options: bool = True, titre="Évènements enregistrés pour cet étudiant", + cache_key: str = "", ) -> tuple[bool, Response | str]: """ Prépare un tableau d'assiduités / justificatifs @@ -1478,6 +1397,13 @@ def _prepare_tableau( fmt = request.args.get("fmt", "html") + # Ordre + ordre: tuple[str, str | bool] = None + ordre_col: str = request.args.get("order_col", None) + ordre_tri: str = request.args.get("order", None) + if ordre_col is not None and ordre_tri is not None: + ordre = (ordre_col, ordre_tri == "ascending") + if options is None: options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions() @@ -1488,12 +1414,15 @@ def _prepare_tableau( show_reta=show_reta, show_desc=show_desc, show_etu=afficher_etu, + order=ordre, ) table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti( table_data=data, options=options, filtre=filtre, + no_pagination=fmt.startswith("xls"), + titre=cache_key, ) if fmt.startswith("xls"): @@ -1565,30 +1494,22 @@ def tableau_assiduite_actions(): if obj_type == "assiduite": # Construction du menu module - # XXX ca ne va pas car cela ne prend qu'un semestre - # TODO reprendre le menu de la page ajout_assiduite_etud - formsemestre = objet.get_formsemestre() - if formsemestre: - if objet.moduleimpl_id is not None: - module = objet.moduleimpl_id - elif objet.external_data is not None: - module = objet.external_data.get("module", "") - module = module.lower() if isinstance(module, str) else module - module = _module_selector(formsemestre, module) - else: - module = "pas de semestre correspondant" + module = _module_selector_multiple(objet.etudiant, objet.moduleimpl_id) return render_template( "assiduites/pages/tableau_assiduite_actions.j2", - sco=ScoData(etud=objet.etudiant), - # XXX type semble être utilisé qq part, ne pas changer - type="Justificatif" if obj_type == "justificatif" else "Assiduité", action=action, + can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView) + or (obj_type == "justificatif" and current_user.id == objet.user_id), etud=objet.etudiant, - objet=_preparer_objet(obj_type, objet), - objet_name=objet_name, - obj_id=obj_id, moduleimpl=module, + obj_id=obj_id, + objet_name=objet_name, + objet=_preparer_objet(obj_type, objet), + sco=ScoData(etud=objet.etudiant), + title=f"Assiduité {objet.etudiant.nom_short}", + # type utilisé dans les actions modifier / détails (modifier.j2, details.j2) + type="Justificatif" if obj_type == "justificatif" else "Assiduité", ) # ----- Cas POST if obj_type == "assiduite": @@ -1761,7 +1682,9 @@ def _preparer_objet( @scodoc @permission_required(Permission.AbsChange) def signal_assiduites_diff(): - """TODO documenter""" + """TODO documenter + Utilisé notamment par "Saisie différée" sur tableau de bord semetstre" + """ # Récupération des paramètres de la requête group_ids: list[int] = request.args.get("group_ids", None) formsemestre_id: int = request.args.get("formsemestre_id", -1) @@ -1784,7 +1707,7 @@ def signal_assiduites_diff(): ) date_fin: datetime.date = date_deb + datetime.timedelta(days=6) - etudiants: list[dict] = [] + etudiants: list[Identite] = [] # --- Vérification de la date --- real_date = scu.is_iso_formated(date, True).date() @@ -1812,15 +1735,9 @@ def signal_assiduites_diff(): # Récupération des étudiants etudiants.extend( - [ - sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] - for m in groups_infos.members - ] + [Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members] ) - # XXX utiliser des instances d'Identite et non des dict - # puis trier avec etud.sort_key - # afin de bien prendre en compte nom usuel etc - etudiants = list(sorted(etudiants, key=lambda x: x["nom"])) + etudiants = list(sorted(etudiants, key=lambda etud: etud.sort_key)) # Génération de l'HTML @@ -1849,32 +1766,29 @@ def signal_assiduites_diff(): grp + ' ' + groups_infos.groups_titles + "" ) - return HTMLBuilder( - header, - render_template( - "assiduites/pages/signal_assiduites_diff.j2", - diff=_differee( - etudiants=etudiants, - moduleimpl_select=_module_selector( - formsemestre, request.args.get("moduleimpl_id", None) - ), - date=date, - periode={ - "deb": formsemestre.date_debut.isoformat(), - "fin": formsemestre.date_fin.isoformat(), - }, + return render_template( + "assiduites/pages/signal_assiduites_diff.j2", + defaultDates=_get_days_between_dates(date_deb, date_fin), + defdem=_get_etuds_dem_def(formsemestre), + diff=_differee( + etudiants=etudiants, + moduleimpl_select=_module_selector( + formsemestre, request.args.get("moduleimpl_id", None) ), - gr=gr_tit, - sem=formsemestre.titre_num(), - defdem=_get_etuds_dem_def(formsemestre), - timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"), - timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"), - timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"), - defaultDates=_get_days_between_dates(date_deb, date_fin), - nonworkdays=_non_work_days(), + date=date, + periode={ + "deb": formsemestre.date_debut.isoformat(), + "fin": formsemestre.date_fin.isoformat(), + }, ), - html_sco_header.sco_footer(), - ).build() + gr=gr_tit, + nonworkdays=_non_work_days(), + sco=ScoData(formsemestre=formsemestre), + sem=formsemestre.titre_num(), + timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"), + timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"), + timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"), + ) @bp.route("/signale_evaluation_abs//") @@ -1928,9 +1842,7 @@ def signale_evaluation_abs(etudid: int = None, evaluation_id: int = None): "assiduites.ajout_assiduite_etud", etudid=etudid, evaluation_id=evaluation.id, - date_deb=evaluation.date_debut.strftime( - "%Y-%m-%dT%H:%M:%S" - ), # XXX TODO + date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"), date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"), moduleimpl_id=evaluation.moduleimpl.id, saisie_eval="true", @@ -2194,21 +2106,42 @@ def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> s return render_template( "assiduites/widgets/moduleimpl_selector.j2", - selected=selected, + formsemestre_id=formsemestre.id, modules=modules, moduleimpl_id=moduleimpl_id, + selected=selected, ) -def _dynamic_module_selector() -> str: +def _module_selector_multiple( + etud: Identite, moduleimpl_id: int = None, only_form: FormSemestre = None +) -> str: + """menu HTML