From cada9c24d0f3b5ca7f225898b2f256ecd5f796c6 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 16 Jan 2023 21:05:48 -0300 Subject: [PATCH] WIP: Affichage validation cursus BUT sur page etudiant. --- app/but/cursus_but.py | 117 ++++++++++++++++++++++++++++- app/models/but_refcomp.py | 57 ++++++++++++-- app/models/but_validations.py | 6 ++ app/scodoc/sco_page_etud.py | 42 ++++++++--- app/static/css/cursus_but.css | 42 +++++++++++ app/static/css/ref-competences.css | 75 ++++++++++++------ app/templates/but/cursus_etud.j2 | 26 +++++++ scodoc.py | 19 ++++- 8 files changed, 336 insertions(+), 48 deletions(-) create mode 100644 app/static/css/cursus_but.css create mode 100644 app/templates/but/cursus_etud.j2 diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 15ac1fa377..9ae1671ab4 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -13,7 +13,7 @@ Classe raccordant avec ScoDoc 7: avec la même interface. """ - +import collections from typing import Union from flask import g, url_for @@ -47,12 +47,14 @@ from app.models.validations import ScolarFormSemestreValidation from app.scodoc import sco_codes_parcours as sco_codes from app.scodoc.sco_codes_parcours import RED, UE_STANDARD from app.scodoc import sco_utils as scu -from app.scodoc.sco_exceptions import ScoException, ScoValueError +from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError from app.scodoc import sco_cursus_dut class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): + """Pour compat ScoDoc 7: à revoir pour le BUT""" + def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT): super().__init__(etud, formsemestre_id, res) # Ajustements pour le BUT @@ -65,3 +67,114 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): def parcours_validated(self): "True si le parcours est validé" return False # XXX TODO + + +class EtudCursusBUT: + """L'état de l'étudiant dans son cursus BUT + Liste des niveaux validés/à valider + """ + + def __init__(self, etud: Identite, formation: Formation): + """formation indique la spécialité préparée""" + # Vérifie que l'étudiant est bien inscrit à un sem. de cette formation + if formation.id not in ( + ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions + ): + raise ScoValueError( + f"{etud.nomprenom} non inscrit dans {formation.titre} v{formation.version}" + ) + if not formation.referentiel_competence: + raise ScoNoReferentielCompetences(formation=formation) + # + self.etud = etud + self.formation = formation + self.inscriptions = sorted( + [ + ins + for ins in etud.formsemestre_inscriptions + if ins.formsemestre.formation.referentiel_competence.id + == formation.referentiel_competence.id + ], + key=lambda s: (s.formsemestre.semestre_id, s.formsemestre.date_debut), + ) + "Liste des inscriptions aux sem. de la formation, triées par indice et chronologie" + self.parcour: ApcParcours = self.inscriptions[-1].parcour + "Le parcour à valider: celui du DERNIER semestre suivi (peut être None)" + self.niveaux_by_annee = {} + "{ annee : liste des niveaux à valider }" + self.niveaux: dict[int, ApcNiveau] = {} + "cache les niveaux" + for annee in (1, 2, 3): + niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours( + annee, self.parcour + )[1] + # groupe les niveaux de tronc commun et ceux spécifiques au parcour + self.niveaux_by_annee[annee] = niveaux_d["TC"] + ( + niveaux_d[self.parcour.id] if self.parcour else [] + ) + self.niveaux.update( + {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]} + ) + # Probablement inutile: + # # Cherche les validations de jury enregistrées pour chaque niveau + # self.validations_by_niveau = collections.defaultdict(lambda: []) + # " { niveau_id : [ ApcValidationRCUE ] }" + # for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): + # self.validations_by_niveau[validation_rcue.niveau().id].append( + # validation_rcue + # ) + # self.validation_by_niveau = { + # niveau_id: sorted( + # validations, key=lambda v: sco_codes.BUT_CODES_ORDERED[v.code] + # )[0] + # for niveau_id, validations in self.validations_by_niveau.items() + # } + # "{ niveau_id : meilleure validation pour ce niveau }" + + self.validation_par_competence_et_annee = {} + "{ competence_id : { 'BUT1' : validation_rcue, ... } }" + for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): + niveau = validation_rcue.niveau() + if not niveau.competence.id in self.validation_par_competence_et_annee: + self.validation_par_competence_et_annee[niveau.competence.id] = {} + previous_validation = self.validation_par_competence_et_annee.get( + niveau.competence.id + ) + # prend la "meilleure" validation + if (not previous_validation) or ( + sco_codes.BUT_CODES_ORDERED[validation_rcue.code] + > sco_codes.BUT_CODES_ORDERED[previous_validation.code] + ): + self.validation_par_competence_et_annee[niveau.competence.id][ + niveau.annee + ] = validation_rcue + + self.competences = { + competence.id: competence + for competence in ( + self.parcour.query_competences() + if self.parcour + else self.formation.referentiel_competence.get_competences_tronc_commun() + ) + } + "cache { competence_id : competence }" + + def to_dict(self): + """ + { + competence_id : { + annee : meilleure_validation + } + } + """ + return { + competence.id: { + annee: { + self.validation_par_competence_et_annee.get(competence.id, {}).get( + annee + ) + } + for annee in ("BUT1", "BUT2", "BUT3") + } + for competence in self.competences.values() + } diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index 647e6ee89a..425ff1923c 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -94,9 +94,10 @@ class ApcReferentielCompetences(db.Model, XMLModel): return "" return self.version_orebut.split()[0] - def to_dict(self): + def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True): """Représentation complète du ref. de comp. comme un dict. + Si parcours est une liste de parcours, restreint l'export aux parcours listés. """ return { "dept_id": self.dept_id, @@ -111,8 +112,14 @@ class ApcReferentielCompetences(db.Model, XMLModel): if self.scodoc_date_loaded else "", "scodoc_orig_filename": self.scodoc_orig_filename, - "competences": {x.titre: x.to_dict() for x in self.competences}, - "parcours": {x.code: x.to_dict() for x in self.parcours}, + "competences": { + x.titre: x.to_dict(with_app_critiques=with_app_critiques) + for x in self.competences + }, + "parcours": { + x.code: x.to_dict() + for x in (self.parcours if parcours is None else parcours) + }, } def get_niveaux_by_parcours( @@ -174,6 +181,27 @@ class ApcReferentielCompetences(db.Model, XMLModel): niveaux_by_parcours_no_tc["TC"] = niveaux_tc return parcours, niveaux_by_parcours_no_tc + def get_competences_tronc_commun(self) -> list["ApcCompetence"]: + """Liste des compétences communes à tous les parcours du référentiel.""" + parcours = self.parcours.all() + if not parcours: + return [] + + ids = set.intersection( + *[ + {competence.id for competence in parcour.query_competences()} + for parcour in parcours + ] + ) + return sorted( + [ + competence + for competence in parcours[0].query_competences() + if competence.id in ids + ], + key=lambda c: c.numero or 0, + ) + class ApcCompetence(db.Model, XMLModel): "Compétence" @@ -215,7 +243,7 @@ class ApcCompetence(db.Model, XMLModel): def __repr__(self): return f"" - def to_dict(self): + def to_dict(self, with_app_critiques=True): "repr dict recursive sur situations, composantes, niveaux" return { "id_orebut": self.id_orebut, @@ -227,7 +255,10 @@ class ApcCompetence(db.Model, XMLModel): "composantes_essentielles": [ x.to_dict() for x in self.composantes_essentielles ], - "niveaux": {x.annee: x.to_dict() for x in self.niveaux}, + "niveaux": { + x.annee: x.to_dict(with_app_critiques=with_app_critiques) + for x in self.niveaux + }, } def to_dict_bul(self) -> dict: @@ -293,13 +324,15 @@ class ApcNiveau(db.Model, XMLModel): return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={ self.annee!r} {self.competence!r}>""" - def to_dict(self): - "as a dict, recursif sur les AC" + def to_dict(self, with_app_critiques=True): + "as a dict, recursif (ou non) sur les AC" return { "libelle": self.libelle, "annee": self.annee, "ordre": self.ordre, - "app_critiques": {x.code: x.to_dict() for x in self.app_critiques}, + "app_critiques": {x.code: x.to_dict() for x in self.app_critiques} + if with_app_critiques + else {}, } def to_dict_bul(self): @@ -471,6 +504,14 @@ class ApcParcours(db.Model, XMLModel): d["annees"] = {x.ordre: x.to_dict() for x in self.annees} return d + def query_competences(self) -> flask_sqlalchemy.BaseQuery: + "Les compétences associées à ce parcours" + return ( + ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours) + .filter_by(parcours_id=self.id) + .order_by(ApcCompetence.numero) + ) + class ApcAnneeParcours(db.Model, XMLModel): id = db.Column(db.Integer, primary_key=True) diff --git a/app/models/but_validations.py b/app/models/but_validations.py index 52826003e6..0f3e239e7d 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -76,6 +76,12 @@ class ApcValidationRCUE(db.Model): # Par convention, il est donné par la seconde UE return self.ue2.niveau_competence + def to_dict(self): + "as a dict" + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + return d + def to_dict_bul(self) -> dict: "Export dict pour bulletins: le code et le niveau de compétence" niveau = self.niveau() diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 55bfa0e929..7115d21b02 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -30,14 +30,15 @@ Fiche description d'un étudiant et de son parcours """ -from flask import abort, url_for, g, request +from flask import abort, url_for, g, render_template, request from flask_login import current_user import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log -from app.but import jury_but_view -from app.models.etudiants import make_etud_args +from app.but import cursus_but, jury_but_view +from app.models.etudiants import Identite, make_etud_args +from app.models.formsemestre import FormSemestre from app.scodoc import html_sco_header from app.scodoc import htmlutils from app.scodoc import sco_archives_etud @@ -169,11 +170,12 @@ def ficheEtud(etudid=None): if not etuds: log(f"ficheEtud: etudid={etudid!r} request.args={request.args!r}") raise ScoValueError("Étudiant inexistant !") - etud = etuds[0] - etudid = etud["etudid"] - sco_etud.fill_etuds_info([etud]) + etud_ = etuds[0] # transition: etud_ à éliminer et remplacer par etud + etudid = etud_["etudid"] + etud = Identite.query.get(etudid) + sco_etud.fill_etuds_info([etud_]) # - info = etud + info = etud_ info["ScoURL"] = scu.ScoURL() info["authuser"] = authuser info["info_naissance"] = info["date_naissance"] @@ -181,7 +183,7 @@ def ficheEtud(etudid=None): 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) + info["etudfoto"] = sco_photos.etud_photo_html(etud_) if ( (not info["domicile"]) and (not info["codepostaldomicile"]) @@ -206,7 +208,7 @@ def ficheEtud(etudid=None): info["emaillink"] = ", ".join( [ '%s' % (m, m) - for m in [etud["email"], etud["emailperso"]] + for m in [etud_["email"], etud_["emailperso"]] if m ] ) @@ -277,7 +279,7 @@ def ficheEtud(etudid=None): sem_info[sem["formsemestre_id"]] = grlink if info["sems"]: - Se = sco_cursus.get_situation_etud_cursus(etud, info["last_formsemestre_id"]) + Se = sco_cursus.get_situation_etud_cursus(etud_, info["last_formsemestre_id"]) info["liste_inscriptions"] = formsemestre_recap_parcours_table( Se, etudid, @@ -454,6 +456,18 @@ def ficheEtud(etudid=None): # 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"][-1]["formsemestre_id"]) + if last_sem.formation.is_apc(): + but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation) + info["but_cursus_mkup"] = render_template( + "but/cursus_etud.j2", + cursus=but_cursus, + scu=scu, + ) + tmpl = """
@@ -488,6 +502,8 @@ def ficheEtud(etudid=None): %(but_infos_mkup)s +%(but_cursus_mkup)s +
%(adm_data)s @@ -524,7 +540,11 @@ def ficheEtud(etudid=None): """ header = html_sco_header.sco_header( page_title="Fiche étudiant %(prenom)s %(nom)s" % info, - cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/jury_but.css"], + cssstyles=[ + "libjs/jQuery-tagEditor/jquery.tag-editor.css", + "css/jury_but.css", + "css/cursus_but.css", + ], javascripts=[ "libjs/jinplace-1.2.1.min.js", "js/ue_list.js", diff --git a/app/static/css/cursus_but.css b/app/static/css/cursus_but.css new file mode 100644 index 0000000000..79cde97cb0 --- /dev/null +++ b/app/static/css/cursus_but.css @@ -0,0 +1,42 @@ +/* Affichage cursus BUT étudiant (sur sa fiche) */ + + +.cursus_but { + margin-left: 32px; + display: inline-grid; + grid-template-columns: repeat(4, auto); + gap: 8px; +} + +.cursus_but>* { + display: flex; + align-items: center; + padding-top: 0px; + padding-bottom: 0px; + padding-left: 16px; + padding-right: 0px; + + background: #FFF; + border: 1px solid #aaa; + border-radius: 8px; +} + +.cursus_but>div.cb_head { + background: rgb(242, 242, 238); + border: none; + border-radius: 0px; + border-bottom: 1px solid gray; + font-weight: bold; +} + +div.cb_titre_competence { + background: #09c !important; + color: #FFF; + padding: 8px !important; +} + +div.code_rcue { + padding-top: 8px; + padding-bottom: 8px; + position: relative; +} \ No newline at end of file diff --git a/app/static/css/ref-competences.css b/app/static/css/ref-competences.css index 96e857d071..61586bc4de 100644 --- a/app/static/css/ref-competences.css +++ b/app/static/css/ref-competences.css @@ -1,24 +1,27 @@ -:host{ +:host { font-family: Verdana; - background: #222; + background: rgb(14, 5, 73); display: block; padding: 12px 32px; color: #FFF; max-width: 1000px; margin: auto; } -h1{ + +h1 { font-weight: 100; } + /**********************/ /* Zone parcours */ /**********************/ -.parcours{ +.parcours { display: flex; gap: 4px; padding-right: 4px; } -.parcours>div{ + +.parcours>div { background: #09c; font-size: 18px; text-align: center; @@ -29,65 +32,89 @@ h1{ transition: 0.1s; opacity: 0.7; } + .parcours>div:hover, -.competence>div:hover{ +.competence>div:hover { color: #ccc; } -.parcours>.focus{ + +.parcours>.focus { opacity: 1; } /**********************/ /* Zone compétences */ /**********************/ -.competences{ - display: grid; +.competences { + display: grid; margin-top: 8px; row-gap: 4px; } -.competences>div{ + +.competences>div { padding: 4px 8px; - border-radius: 4px; + border-radius: 4px; cursor: pointer; width: var(--competence-size); margin-right: 4px; } -.comp1{background:#a44} -.comp2{background:#84a} -.comp3{background:#a84} -.comp4{background:#8a4} -.comp5{background:#4a8} -.comp6{background:#48a} +.comp1 { + background: #a44 +} -.competences>.focus{ +.comp2 { + background: #84a +} + +.comp3 { + background: #a84 +} + +.comp4 { + background: #8a4 +} + +.comp5 { + background: #4a8 +} + +.comp6 { + background: #48a +} + +.competences>.focus { outline: 2px solid; } /**********************/ /* Zone AC */ /**********************/ -h2{ +h2 { display: table; padding: 8px 16px; font-size: 20px; border-radius: 16px 0; } -.ACs{ + +.ACs { padding-right: 4px; } -.AC li{ + +.AC li { display: grid; grid-template-columns: auto 1fr; align-items: start; gap: 4px; margin-bottom: 4px; - border-bottom: 1px solid; + border-bottom: 1px solid; } -.AC li>div:nth-child(1){ + +.AC li>div:nth-child(1) { padding: 2px 4px; border-radius: 4px; } -.AC li>div:nth-child(2){ + +.AC li>div:nth-child(2) { padding-bottom: 2px; } \ No newline at end of file diff --git a/app/templates/but/cursus_etud.j2 b/app/templates/but/cursus_etud.j2 new file mode 100644 index 0000000000..04f450d2e4 --- /dev/null +++ b/app/templates/but/cursus_etud.j2 @@ -0,0 +1,26 @@ +{# Affichage cursus BUT fiche étudiant #} + +
+
+
BUT 1
+
BUT 2
+
BUT 3
+ {% for competence_id in cursus.to_dict() %} +
{{ cursus.competences[competence_id].titre }}
+ {% for annee in ('BUT1', 'BUT2', 'BUT3') %} + {% set validation = cursus.validation_par_competence_et_annee.get(competence_id, {}).get(annee) %} +
+ {% if validation %} +
+
{{validation.code}}
+
Validé le {{ + validation.date.strftime("%d/%m/%Y à %H:%M") + }}
+
+ {% else %} + - + {%endif%} +
+ {% endfor %} + {% endfor %} +
\ No newline at end of file diff --git a/scodoc.py b/scodoc.py index 1484883f08..7f46030e72 100755 --- a/scodoc.py +++ b/scodoc.py @@ -33,6 +33,13 @@ from app.models import Identite from app.models import ModuleImpl, ModuleImplInscription from app.models import Partition from app.models import ScolarFormSemestreValidation +from app.models.but_refcomp import ( + ApcCompetence, + ApcNiveau, + ApcParcours, + ApcReferentielCompetences, +) +from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models.evaluations import Evaluation from app.scodoc.sco_logos import make_logo_local from app.scodoc.sco_permissions import Permission @@ -57,6 +64,12 @@ def make_shell_context(): from app.scodoc import sco_utils as scu return { + "ApcCompetence": ApcCompetence, + "ApcNiveau": ApcNiveau, + "ApcParcours": ApcParcours, + "ApcReferentielCompetences": ApcReferentielCompetences, + "ApcValidationRCUE": ApcValidationRCUE, + "ApcValidationAnnee": ApcValidationAnnee, "ctx": app.test_request_context(), "current_app": flask.current_app, "current_user": current_user, @@ -71,21 +84,21 @@ def make_shell_context(): "login_user": login_user, "logout_user": logout_user, "mapp": mapp, - "models": models, "Matiere": Matiere, + "models": models, "Module": Module, "ModuleImpl": ModuleImpl, "ModuleImplInscription": ModuleImplInscription, - "Partition": Partition, "ndb": ndb, "notes": notes, "np": np, + "Partition": Partition, "pd": pd, "Permission": Permission, "pp": pp, - "Role": Role, "res_sem": res_sem, "ResultatsSemestreBUT": ResultatsSemestreBUT, + "Role": Role, "scolar": scolar, "ScolarFormSemestreValidation": ScolarFormSemestreValidation, "ScolarNews": models.ScolarNews,