diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 3a63a4db5..8ed3fd782 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -18,6 +18,7 @@ from collections.abc import Iterable from operator import attrgetter from flask import g, url_for +from flask_sqlalchemy.query import Query from app import db, log from app.comp.res_but import ResultatsSemestreBUT @@ -393,6 +394,26 @@ def but_ects_valides( et ne les compte qu'une fois même en cas de redoublement avec re-validation. Si annees_but est spécifié, un iterable "BUT1, "BUT2" par exemple, ne prend que ces années. """ + validations = but_validations_ues(etud, referentiel_competence_id, annees_but) + ects_dict = {} + for v in validations: + key = (v.ue.semestre_idx, v.ue.niveau_competence.id) + if v.code in CODES_UE_VALIDES: + ects_dict[key] = v.ue.ects + + return int(sum(ects_dict.values())) if ects_dict else 0 + + +def but_validations_ues( + etud: Identite, + referentiel_competence_id: int, + annees_but: None | Iterable[str] = None, +) -> Query: + """Query les validations d'UEs pour cet étudiant + dans des UEs appartenant à ce référentiel de compétence + et en option pour les années BUT indiquées. + annees_but : None (tout) ou liste [ "BUT1", ... ] + """ validations = ( ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) .filter(ScolarFormSemestreValidation.ue_id != None) @@ -403,18 +424,10 @@ def but_ects_valides( if annees_but: validations = validations.filter(ApcNiveau.annee.in_(annees_but)) # Et restreint au référentiel de compétence: - validations = validations.join(ApcCompetence).filter_by( + return validations.join(ApcCompetence).filter_by( referentiel_id=referentiel_competence_id ) - ects_dict = {} - for v in validations: - key = (v.ue.semestre_idx, v.ue.niveau_competence.id) - if v.code in CODES_UE_VALIDES: - ects_dict[key] = v.ue.ects - - return int(sum(ects_dict.values())) if ects_dict else 0 - def etud_ues_de_but1_non_validees( etud: Identite, formation: Formation, parcour: ApcParcours diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 4d23118ee..8ac11d39f 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -845,11 +845,11 @@ class FormSemestre(models.ScoDocModel): else: return ", ".join([u.get_nomcomplet() for u in self.responsables]) - def est_responsable(self, user: User): + def est_responsable(self, user: User) -> bool: "True si l'user est l'un des responsables du semestre" return user.id in [u.id for u in self.responsables] - def est_chef_or_diretud(self, user: User = None): + def est_chef_or_diretud(self, user: User | None = None) -> bool: "Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre" user = user or current_user return user.has_permission(Permission.EditFormSemestre) or self.est_responsable( @@ -867,7 +867,7 @@ class FormSemestre(models.ScoDocModel): return True # typiquement admin, chef dept return self.est_responsable(user) - def can_edit_jury(self, user: User = None): + def can_edit_jury(self, user: User | None = None): """Vrai si utilisateur (par def. current) peut saisir decision de jury dans ce semestre: vérifie permission et verrouillage. """ diff --git a/app/models/validations.py b/app/models/validations.py index 8595b3d2f..e54bc6283 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -2,12 +2,15 @@ """Notes, décisions de jury """ +from flask_sqlalchemy.query import Query from app import db from app import log from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN from app.models.events import Scolog +from app.models.formations import Formation +from app.models.ues import UniteEns from app.scodoc import sco_cache from app.scodoc import sco_utils as scu from app.scodoc.codes_cursus import CODES_UE_VALIDES @@ -113,6 +116,7 @@ class ScolarFormSemestreValidation(db.Model): if self.ue.parcours else ""} {("émise par " + link)} : {self.code}{moyenne} + {self.ue.ects:g} ECTS le {self.event_date.strftime(scu.DATEATIME_FMT)} """ else: @@ -131,6 +135,27 @@ class ScolarFormSemestreValidation(db.Model): else 0.0 ) + @classmethod + def validations_ues( + cls, etud: "Identite", formation_code: str | None = None + ) -> Query: + """Query les validations d'UE pour cet étudiant dans des UEs de formations + du code indiqué, ou toutes si le formation_code est None. + """ + from app.models.formsemestre import FormSemestre + + query = ( + ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) + .filter(ScolarFormSemestreValidation.ue_id != None) + .join(UniteEns) + .join(FormSemestre, ScolarFormSemestreValidation.formsemestre) + ) + if formation_code is not None: + query = query.join(Formation).filter_by(formation_code=formation_code) + return query.order_by( + FormSemestre.semestre_id, UniteEns.numero, UniteEns.acronyme + ) + class ScolarAutorisationInscription(db.Model): """Autorisation d'inscription dans un semestre""" diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 1598abc55..e685fbc7e 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -31,7 +31,7 @@ import time import flask from flask import url_for, flash, g, request -from flask_login import current_user +from flask.templating import render_template import sqlalchemy as sa from app.models import Identite, Evaluation @@ -64,7 +64,6 @@ from app.scodoc import sco_cursus_dut from app.scodoc.sco_cursus_dut import etud_est_inscrit_ue from app.scodoc import sco_preferences from app.scodoc import sco_pv_dict -from app.scodoc.sco_permissions import Permission # ------------------------------------------------------------------------------------ @@ -1249,7 +1248,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite

On ne peut valider ici que les UEs du cursus {formation.titre}

- {_get_etud_ue_cap_html(etud, formsemestre)} + {_get_etud_ue_validations_html(etud, formsemestre)}
@@ -1300,7 +1299,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite return flask.redirect(dest_url) -def _get_etud_ue_cap_html(etud: Identite, formsemestre: FormSemestre) -> str: +def _get_etud_ue_validations_html(etud: Identite, formsemestre: FormSemestre) -> str: """HTML listant les validations d'UEs pour cet étudiant dans des formations de même code que celle du formsemestre indiqué. """ @@ -1319,39 +1318,13 @@ def _get_etud_ue_cap_html(etud: Identite, formsemestre: FormSemestre) -> str: if not validations: return "" - H = [ - f"""
-
Validations d'UEs dans cette formation
-
Liste de toutes les UEs validées par {etud.html_link_fiche()}, - sur des semestres ou déclarées comme "antérieures" (externes). -
-
    """ - ] - for validation in validations: - if validation.formsemestre_id is None: - origine = " enregistrée d'un parcours antérieur (hors ScoDoc)" - else: - origine = f", du semestre {formsemestre.html_link_status()}" - if validation.semestre_id is not None: - origine += f" (S{validation.semestre_id})" - H.append(f"""
  • {validation.html()}""") - if (validation.formsemestre and validation.formsemestre.can_edit_jury()) or ( - current_user and current_user.has_permission(Permission.EtudInscrit) - ): - H.append( - f""" -
    - -
    - """, - ) - else: - H.append(scu.icontag("lock_img", border="0", title="Semestre verrouillé")) - H.append("
  • ") - H.append("
") - return "\n".join(H) + return render_template( + "jury/ue_list_etud_validations.j2", + edit_mode=True, + etud=etud, + titre_boite="Validations d'UEs dans cette formation", + validations=validations, + ) def do_formsemestre_validate_previous_ue( diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index a04c845d8..5fe3561c9 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -121,14 +121,14 @@ def _menu_scolarite( "enabled": def_enabled, }, { - "title": "Inscrire à un module optionnel (ou au sport)", - "endpoint": "notes.formsemestre_inscription_option", + "title": "Désinscrire (en cas d'erreur)", + "endpoint": "notes.formsemestre_desinscription", "args": args, "enabled": authuser.has_permission(Permission.EtudInscrit) and not locked, }, { - "title": "Désinscrire (en cas d'erreur)", - "endpoint": "notes.formsemestre_desinscription", + "title": "Inscrire à un module optionnel (ou au sport)", + "endpoint": "notes.formsemestre_inscription_option", "args": args, "enabled": authuser.has_permission(Permission.EtudInscrit) and not locked, }, @@ -138,12 +138,6 @@ def _menu_scolarite( "args": args, "enabled": formsemestre.can_edit_jury(), }, - { - "title": "Inscrire à un autre semestre", - "endpoint": "notes.formsemestre_inscription_with_modules_form", - "args": {"etudid": etudid}, - "enabled": authuser.has_permission(Permission.EtudInscrit), - }, { "title": "Enregistrer un semestre effectué ailleurs", "endpoint": "notes.formsemestre_ext_create_form", @@ -156,6 +150,12 @@ def _menu_scolarite( "args": args, "enabled": authuser.has_permission(Permission.EditAllNotes), }, + { + "title": "Inscrire à un autre semestre", + "endpoint": "notes.formsemestre_inscription_with_modules_form", + "args": {"etudid": etudid}, + "enabled": authuser.has_permission(Permission.EtudInscrit), + }, ] return htmlutils.make_menu( @@ -317,6 +317,12 @@ def fiche_etud(etudid=None): else: info["link_inscrire_ailleurs"] = "" + info[ + "link_bilan_ects" + ] = f"""ECTS""" else: # non inscrit l = [f"""

Étudiant{etud.e} non inscrit{etud.e}"""] @@ -331,6 +337,7 @@ def fiche_etud(etudid=None): info["liste_inscriptions"] = "\n".join(l) info["link_bul_pdf"] = "" info["link_inscrire_ailleurs"] = "" + info["link_bilan_ects"] = "" # Liste des annotations html_annotations_list = "\n".join( @@ -433,7 +440,9 @@ def fiche_etud(etudid=None): "inscriptions_mkup" ] = f"""

Cursus
{info["liste_inscriptions"]} - {info["link_bul_pdf"]} {info["link_inscrire_ailleurs"]} + {info["link_bul_pdf"]} + {info["link_inscrire_ailleurs"]} + {info["link_bilan_ects"]}
""" # diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index 019cec658..e581b6b4b 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -246,7 +246,7 @@ PREF_CATEGORIES = ( "bul_margins", { "title": "Marges additionnelles des bulletins, en millimètres", - "subtitle": """Le bulletin de notes notes est toujours redimensionné + "subtitle": """Le bulletin de notes classique (pas BUT) est toujours redimensionné pour occuper l'espace disponible entre les marges. """, "related": ("bul", "bul_mail", "pdf"), diff --git a/app/static/css/jury_delete_manual.css b/app/static/css/jury_delete_manual.css index b685384a4..6b0f95b68 100644 --- a/app/static/css/jury_delete_manual.css +++ b/app/static/css/jury_delete_manual.css @@ -9,4 +9,10 @@ span.parcours { div.ue_list_etud_validations ul.liste_validations li { margin-bottom: 8px; +} + +div.ue_list_etud_validations div.total_ects { + font-weight: bold; + margin-top: 16px; + margin-bottom: 12px; } \ No newline at end of file diff --git a/app/templates/jury/etud_bilan_ects.j2 b/app/templates/jury/etud_bilan_ects.j2 new file mode 100644 index 000000000..0908dc7e2 --- /dev/null +++ b/app/templates/jury/etud_bilan_ects.j2 @@ -0,0 +1,24 @@ +{% extends "sco_page.j2" %} + +{% block styles %} + {{super()}} + +{% endblock %} + + +{% block app_content %} +

Bilan des ECTS de {{etud.html_link_fiche()|safe}}

+ +
+ Cette page donne toutes les UEs acquises par l'étudiant (codes ADM, ADJ, ADJR, ADSUP, CMP...) + dans chaque formation qu'il a suivi. +
+ + {% for diplome in formsemestre_by_diplome %} + {% set titre_boite = "Validations d'UEs dans la formation " + titre_by_diplome[diplome] %} + {% set validations = validations_by_diplome[diplome] %} + {% set total_ects = ects_by_diplome[diplome] %} + {% include "jury/ue_list_etud_validations.j2" %} + {% endfor %} + +{% endblock app_content %} diff --git a/app/templates/jury/ue_list_etud_validations.j2 b/app/templates/jury/ue_list_etud_validations.j2 new file mode 100644 index 000000000..4b5343b98 --- /dev/null +++ b/app/templates/jury/ue_list_etud_validations.j2 @@ -0,0 +1,31 @@ +{# Fragment de html pour cadre affichage validations d'une formation #} + +
+
{{titre_boite}}
+
Liste de toutes les UEs validées par {{etud.html_link_fiche()|safe}}, + sur des semestres ou déclarées comme "antérieures" (externes). +
+
    + {% for validation in validations %} + +
  • {{ validation.html() | safe }} + {% if edit_mode %} + {% if validation.formsemestre and validation.formsemestre.can_edit_jury() %} +
    + +
    + {% else %} + {{ scu.icontag("lock_img", border="0", title="Semestre verrouillé") }} + {% endif %} + {% endif %} +
  • + {% endfor %} +
+ {% if total_ects %} +
+ Total ECTS: {{ "%g" % total_ects }} +
+ {% endif %} +
\ No newline at end of file diff --git a/app/views/jury_validations.py b/app/views/jury_validations.py index ccef89465..4d0bd39cc 100644 --- a/app/views/jury_validations.py +++ b/app/views/jury_validations.py @@ -27,7 +27,7 @@ Vues sur les jurys et validations Emmanuel Viennet, 2024 """ - +from collections import defaultdict import datetime import flask from flask import flash, g, redirect, render_template, request, url_for @@ -55,6 +55,7 @@ from app.models import ( FormSemestreInscription, Identite, ScolarAutorisationInscription, + ScolarFormSemestreValidation, ScolarNews, ScoDocSiteConfig, ) @@ -66,6 +67,7 @@ from app.scodoc import ( sco_formsemestre_validation, sco_preferences, ) +from app.scodoc.codes_cursus import CODES_UE_VALIDES from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import ( ScoPermissionDenied, @@ -78,7 +80,6 @@ from app.scodoc.sco_pv_dict import descr_autorisations from app.views import notes_bp as bp from app.views import ScoData - # --- FORMULAIRE POUR VALIDATION DES UE ET SEMESTRES @@ -902,3 +903,60 @@ def jury_delete_manual(etudid: int): """Efface toute les décisions d'une année pour cet étudiant""" etud = Identite.get_etud(etudid) return jury_edit_manual.jury_delete_manual(etud) + + +@bp.route("/etud_bilan_ects/") +@scodoc +@permission_required(Permission.ScoView) +def etud_bilan_ects(etudid: int): + """Page bilan de tous els ECTS acquis par un étudiant. + Plusieurs formations (eg DUT, LP) peuvent être concernées. + """ + etud = Identite.get_etud(etudid) + # Cherche les formations différentes (au sens des ECTS) + # suivies par l'étudiant: regroupe ses formsemestres + # diplome est la clé: en classique le code formation, en BUT le referentiel_competence_id + formsemestre_by_diplome = defaultdict(list) + for formsemestre in etud.get_formsemestres(recent_first=True): + diplome = ( + formsemestre.formation.referentiel_competence.id + if ( + formsemestre.formation.is_apc() + and formsemestre.formation.referentiel_competence + ) + else formsemestre.formation.formation_code + ) + formsemestre_by_diplome[diplome].append(formsemestre) + + # Pour chaque liste de formsemestres d'un même "diplôme" + # liste les UE validées avec leurs ECTS + ects_by_diplome = {} + titre_by_diplome = {} # { diplome : titre } + validations_by_diplome = {} # { diplome : query validations UEs } + for diplome, formsemestres in formsemestre_by_diplome.items(): + formsemestre = formsemestres[0] + titre_by_diplome[diplome] = formsemestre.formation.get_titre_version() + if formsemestre.formation.is_apc(): + validations = cursus_but.but_validations_ues(etud, diplome) + else: + validations = ScolarFormSemestreValidation.validations_ues( + etud, formsemestre.formation.formation_code + ) + validations_by_diplome[diplome] = [ + validation + for validation in validations + if validation.code in CODES_UE_VALIDES + ] + ects_by_diplome[diplome] = sum( + (validation.ue.ects or 0.0) + for validation in validations_by_diplome[diplome] + ) + + return render_template( + "jury/etud_bilan_ects.j2", + etud=etud, + ects_by_diplome=ects_by_diplome, + formsemestre_by_diplome=formsemestre_by_diplome, + titre_by_diplome=titre_by_diplome, + validations_by_diplome=validations_by_diplome, + ) diff --git a/scodoc.py b/scodoc.py index 582fb3a3e..8baf3cab0 100755 --- a/scodoc.py +++ b/scodoc.py @@ -56,9 +56,9 @@ cli.register(app) @app.context_processor def inject_sco_utils(): - "Make scu and sco available in all Jinja templates" + "Make Permission, sco and scu available in all Jinja templates" # if modified, put the same in conftest.py#27 - return {"scu": scu, "sco": ScoData()} + return {"Permission": Permission, "scu": scu, "sco": ScoData()} @app.shell_context_processor diff --git a/tests/conftest.py b/tests/conftest.py index 26372dc9f..5d7a1e8ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ from app import models from app.auth.models import User, Role from app.auth.models import get_super_admin from app.scodoc import notesdb as ndb +from app.scodoc.sco_permissions import Permission import app.scodoc.sco_utils as scu from app.views import ScoData @@ -28,7 +29,7 @@ def test_client(): @apptest.context_processor def inject_sco_utils(): "Make scu available in all Jinja templates" - return {"scu": scu, "sco": ScoData()} + return {"Permission": Permission, "scu": scu, "sco": ScoData()} with apptest.test_request_context(): # initialize scodoc "g":