From 845b0e936308f1d7637608ab2efe72178ec5953f Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 19 Feb 2025 16:38:22 +0100 Subject: [PATCH] =?UTF-8?q?Editions=20formations=20BUT:=20meilleurs=20aver?= =?UTF-8?q?tissements=20et=20possibilit=C3=A9=20de=20changer=20des=20assoc?= =?UTF-8?q?iations=20en=20super-admin=20si=20d=C3=A9cision=20saisies.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/formations.py | 36 +++++++++++++++++--- app/but/cursus_but.py | 16 +++++---- app/comp/bonus_spo.py | 2 +- app/models/ues.py | 11 +++--- app/scodoc/sco_archives_formsemestre.py | 2 +- app/scodoc/sco_page_etud.py | 4 +-- app/scodoc/sco_recapcomplet.py | 45 +++++++++++++++---------- app/static/css/gt_table.css | 6 ++++ app/static/css/scodoc.css | 4 +++ app/tables/jury_recap.py | 11 ++++-- app/tables/table_builder.py | 2 ++ app/templates/but/parcour_formation.j2 | 20 +++++++++-- tests/api/test_api_etudiants.py | 14 ++++---- 13 files changed, 125 insertions(+), 48 deletions(-) diff --git a/app/api/formations.py b/app/api/formations.py index def1c12e7..815233d3e 100644 --- a/app/api/formations.py +++ b/app/api/formations.py @@ -209,26 +209,40 @@ def ue_set_parcours(ue_id: int): return {"status": ok, "message": error_message} +@bp.route( + "/formation/ue//assoc_niveau//force", + defaults={"force": True}, + methods=["POST"], +) +@api_web_bp.route( + "/formation/ue//assoc_niveau//force", + defaults={"force": True}, + methods=["POST"], +) @bp.route( "/formation/ue//assoc_niveau/", + defaults={"force": False}, methods=["POST"], ) @api_web_bp.route( "/formation/ue//assoc_niveau/", + defaults={"force": False}, methods=["POST"], ) @login_required @scodoc @permission_required(Permission.EditFormation) @as_json -def ue_assoc_niveau(ue_id: int, niveau_id: int): - """Associe l'UE au niveau de compétence.""" +def ue_assoc_niveau(ue_id: int, niveau_id: int, force=False): + """Associe l'UE au niveau de compétence. + Si force, modifie l'association même si des décisions de jury sont présentes. + """ query = UniteEns.query.filter_by(id=ue_id) if g.scodoc_dept: query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id) ue: UniteEns = query.first_or_404() niveau: ApcNiveau = ApcNiveau.get_or_404(niveau_id) - ok, error_message = ue.set_niveau_competence(niveau) + ok, error_message = ue.set_niveau_competence(niveau, force=force) if not ok: if g.scodoc_dept: # "usage web" flash(error_message, "error") @@ -238,19 +252,31 @@ def ue_assoc_niveau(ue_id: int, niveau_id: int): return {"status": 0} +@bp.route( + "/formation/ue//desassoc_niveau/force", + defaults={"force": True}, + methods=["POST"], +) +@api_web_bp.route( + "/formation/ue//desassoc_niveau/force", + defaults={"force": True}, + methods=["POST"], +) @bp.route( "/formation/ue//desassoc_niveau", + defaults={"force": False}, methods=["POST"], ) @api_web_bp.route( "/formation/ue//desassoc_niveau", + defaults={"force": False}, methods=["POST"], ) @login_required @scodoc @permission_required(Permission.EditFormation) @as_json -def ue_desassoc_niveau(ue_id: int): +def ue_desassoc_niveau(ue_id: int, force=False): """Désassocie cette UE de son niveau de compétence (si elle n'est pas associée, ne fait rien). """ @@ -258,7 +284,7 @@ def ue_desassoc_niveau(ue_id: int): if g.scodoc_dept: query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id) ue: UniteEns = query.first_or_404() - ok, error_message = ue.set_niveau_competence(None) + ok, error_message = ue.set_niveau_competence(None, force=force) if not ok: if g.scodoc_dept: # "usage web" flash(error_message, "error") diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 3785f694b..a31f34003 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -96,11 +96,13 @@ def but_parcours_validated(etud: Identite, parcour_id: int | None) -> bool: class EtudCursusBUT: """L'état de l'étudiant dans son cursus BUT Liste des niveaux validés/à valider - (utilisé pour le résumé sur la fiche étudiant) + (utilisé pour le résumé sur la fiche étudiant). """ def __init__(self, etud: Identite, formation: Formation): - """formation indique la spécialité préparée""" + """formation indique la spécialité préparée. + Peut lever l'exception ScoValueError ou ScoNoReferentielCompetences + """ # 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 @@ -142,10 +144,10 @@ class EtudCursusBUT: if niveau is None: raise ScoValueError( f"""UE d'un RCUE ({ - validation_rcue.ue1.acronyme}/{validation_rcue.ue1.acronyme + validation_rcue.ue1.acronyme}/{validation_rcue.ue2.acronyme }) non associée à un niveau de compétence. Vérifiez la formation et les associations de ses UEs. - Étudiant {etud.nomprenom}. + Étudiant {etud.html_link_fiche()}. Formations concernées: {validation_rcue.ue2.acronyme}. - """ + """, + safe=True, ) if not niveau.competence.id in self.validation_par_competence_et_annee: self.validation_par_competence_et_annee[niveau.competence.id] = {} @@ -625,8 +628,9 @@ def formsemestre_warning_apc_setup( ) if nb_ues_sans_parcours != nb_ues_tot: H.append( - """Le semestre n'est associé à aucun parcours, + f"""Le semestre n'est associé à aucun parcours, mais les UEs de la formation ont des parcours + ({nb_ues_sans_parcours} UEs sans parcours sur {nb_ues_tot} UEs au total). """ ) # Vérifie les niveaux de chaque parcours diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index a480b2109..7c9f33550 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -1473,7 +1473,7 @@ class BonusTarbes(BonusIUTRennes1): """Calcul bonus optionnels (sport, culture), règle IUT de Tarbes.
    -
  • Les étudiants opeuvent suivre un ou plusieurs activités optionnelles notées. +
  • Les étudiants peuvent suivre un ou plusieurs activités optionnelles notées. La meilleure des notes obtenue est prise en compte, si elle est supérieure à 10/20.
  • Le trentième des points au dessus de 10 est ajouté à la moyenne des UE en BUT, diff --git a/app/models/ues.py b/app/models/ues.py index 6c1340247..7cf25bc63 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -464,7 +464,9 @@ class UniteEns(models.ScoDocModel): > 0 ) - def set_niveau_competence(self, niveau: ApcNiveau | None) -> tuple[bool, str]: + def set_niveau_competence( + self, niveau: ApcNiveau | None, force: bool = False + ) -> tuple[bool, str]: """Associe cette UE au niveau de compétence indiqué. Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas de tronc commun). @@ -473,7 +475,8 @@ class UniteEns(models.ScoDocModel): Si niveau est None, désassocie. - Si l'UE est utilisée dans un validation de RCUE, on ne peut plus la changer de niveau. + Si l'UE est utilisée dans un validation de RCUE, on ne peut plus la changer + de niveau, sauf si force est vrai. Returns - True if (de)association done, False on error. @@ -486,7 +489,7 @@ class UniteEns(models.ScoDocModel): "La formation n'est pas associée à un référentiel de compétences", ) # UE utilisée dans des validations RCUE ? - if self.is_used_in_validation_rcue(): + if not force and self.is_used_in_validation_rcue(): return ( False, "UE utilisée dans un RCUE validé: son niveau ne peut plus être modifié", @@ -521,7 +524,7 @@ class UniteEns(models.ScoDocModel): db.session.commit() # Invalidation du cache self.formation.invalidate_cached_sems() - log(f"ue.set_niveau_competence( {self}, {niveau} )") + log(f"ue.set_niveau_competence( {self}, {niveau}, force={force} )") return True, "" def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]: diff --git a/app/scodoc/sco_archives_formsemestre.py b/app/scodoc/sco_archives_formsemestre.py index 892e9438a..3e4b54d20 100644 --- a/app/scodoc/sco_archives_formsemestre.py +++ b/app/scodoc/sco_archives_formsemestre.py @@ -117,7 +117,7 @@ def do_formsemestre_archive( dept_id=formsemestre.dept_id, ) # Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes) - table_html, _, _ = gen_formsemestre_recapcomplet_html_table( + table_html, _, _, _ = gen_formsemestre_recapcomplet_html_table( formsemestre, res, include_evaluations=True ) if table_html: diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 0f70477f4..b925716ab 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -58,7 +58,7 @@ from app.scodoc import ( ) 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_exceptions import ScoNoReferentielCompetences, 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 @@ -473,7 +473,7 @@ def fiche_etud(etudid=None): if last_formsemestre and last_formsemestre.formation.is_apc(): try: but_cursus = cursus_but.EtudCursusBUT(etud, last_formsemestre.formation) - except ScoValueError: + except (ScoValueError, ScoNoReferentielCompetences): but_cursus = None refcomp = last_formsemestre.formation.referentiel_competence if refcomp: diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 55237e5e3..8e9f93403 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -112,7 +112,7 @@ def formsemestre_recapcomplet( visible_col_ids=visible_col_ids, ) - table_html, _, freq_codes_annuels = _formsemestre_recapcomplet_to_html( + table_html, _, freq_codes_annuels, warnings = _formsemestre_recapcomplet_to_html( formsemestre, filename=filename, mode_jury=mode_jury, @@ -120,11 +120,17 @@ def formsemestre_recapcomplet( selected_etudid=selected_etudid, ) - H = [ - # sco_formsemestre_status.formsemestre_status_head( - # formsemestre_id=formsemestre_id - # ), - ] + H = [] + if warnings: + H.append( + f""" +
    +
    Avertissements
    +
    • + {'
    • '.join(warnings)} +
    +
    """ + ) if len(formsemestre.inscriptions) > 0: H.append( f"""
    @@ -300,15 +306,17 @@ def _formsemestre_recapcomplet_to_html( if tabformat not in ("html", "evals"): raise ScoValueError("invalid table format") res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - table_html, table, freq_codes_annuels = gen_formsemestre_recapcomplet_html_table( - formsemestre, - res, - include_evaluations=(tabformat == "evals"), - mode_jury=mode_jury, - filename=filename, - selected_etudid=selected_etudid, + table_html, table, freq_codes_annuels, warnings = ( + gen_formsemestre_recapcomplet_html_table( + formsemestre, + res, + include_evaluations=(tabformat == "evals"), + mode_jury=mode_jury, + filename=filename, + selected_etudid=selected_etudid, + ) ) - return table_html, table, freq_codes_annuels + return table_html, table, freq_codes_annuels, warnings def _formsemestre_recapcomplet_to_file( @@ -473,7 +481,7 @@ def gen_formsemestre_recapcomplet_html_table( mode_jury=False, filename="", selected_etudid=None, -) -> tuple[str, TableRecap, collections.Counter]: +) -> tuple[str, TableRecap, collections.Counter, list[str]]: """Construit table recap pour le BUT Cache le résultat pour le semestre. Note: on cache le HTML et non l'objet Table. @@ -508,11 +516,12 @@ def gen_formsemestre_recapcomplet_html_table( freq_codes_annuels = ( table.freq_codes_annuels if hasattr(table, "freq_codes_annuels") else None ) - cache_class.set(formsemestre.id, (table_html, freq_codes_annuels)) + warnings = table.warnings + cache_class.set(formsemestre.id, (table_html, freq_codes_annuels, warnings)) else: - table_html, freq_codes_annuels = table_html_cached + table_html, freq_codes_annuels, warnings = table_html_cached - return table_html, table, freq_codes_annuels + return table_html, table, freq_codes_annuels, warnings def _gen_formsemestre_recapcomplet_table( diff --git a/app/static/css/gt_table.css b/app/static/css/gt_table.css index 850bbe1ce..2baa6f6e3 100644 --- a/app/static/css/gt_table.css +++ b/app/static/css/gt_table.css @@ -65,4 +65,10 @@ div.gt_caption { .dt-scroll-foot { overflow: visible !important; +} + +div.table-warnings { + background-color: yellow; + color: darkred; + max-width: 100%; } \ No newline at end of file diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 7e072b608..28975ef6a 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1284,6 +1284,10 @@ div.sco_box_title { background-color: rgb(209, 255, 214); } +div.sco_box.sco_dashed { + border: 1px dashed red; +} + div.vertical_spacing_but { margin-top: 12px; } diff --git a/app/tables/jury_recap.py b/app/tables/jury_recap.py index 8189f978c..dbd4a8a3e 100644 --- a/app/tables/jury_recap.py +++ b/app/tables/jury_recap.py @@ -24,6 +24,7 @@ from app.scodoc.codes_cursus import ( BUT_BARRE_RCUE, BUT_RCUE_SUFFISANT, ) +from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_utils as scu from app.tables.recap import RowRecap, TableRecap @@ -201,9 +202,13 @@ class TableJury(TableRecap): self.group_titles[group] = f"Compétences {annee}" for row in self.rows: etud = row.etud - cursus_dict = cursus_but.EtudCursusBUT( - etud, self.res.formsemestre.formation - ).to_dict() + try: + cursus_dict = cursus_but.EtudCursusBUT( + etud, self.res.formsemestre.formation + ).to_dict() + except ScoValueError as exc: + cursus_dict = {} + self.warnings.append(exc.args[0]) first = True for competence_id in cursus_dict: for annee in ("BUT1", "BUT2", "BUT3"): diff --git a/app/tables/table_builder.py b/app/tables/table_builder.py index 466ac8e8a..2ae8f89f1 100644 --- a/app/tables/table_builder.py +++ b/app/tables/table_builder.py @@ -117,6 +117,8 @@ class Table(Element): # self.caption = caption self.origin = origin + self.warnings: list[str] = [] + "liste d'avertissements rencontrés en construisant la table" def _prepare(self): """Prepare the table before generation: diff --git a/app/templates/but/parcour_formation.j2 b/app/templates/but/parcour_formation.j2 index 1b0f23968..92de363da 100644 --- a/app/templates/but/parcour_formation.j2 +++ b/app/templates/but/parcour_formation.j2 @@ -124,6 +124,17 @@ Choisissez un parcours... {% endif %} +{% if current_user.is_administrator() %} +
    +Vous êtes super-administrateur. +
    + + +
    +
    +{% endif %} + + {% if parcour %}
    @@ -150,7 +161,7 @@ Choisissez un parcours... function ue_assoc_niveau(event, niveau_id) { let ue_id = event.target.value; let url = ""; - let must_reload = false; + let force = document.getElementById('force_modification').checked; if (ue_id == "") { /* Dé-associe */ ue_id = event.target.dataset.ue_id; @@ -162,7 +173,9 @@ function ue_assoc_niveau(event, niveau_id) { ) }}'; url = desassoc_url.replace('11111', ue_id); - must_reload=true; + if (force) { + url += '/force'; + } } else { const assoc_url = '{{ url_for( @@ -172,6 +185,9 @@ function ue_assoc_niveau(event, niveau_id) { ) }}'; url = assoc_url.replace('11111', ue_id).replace('22222', niveau_id); + if (force) { + url += '/force'; + } } fetch(url, { method: 'POST', diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py index 6ae352c4b..32b16112f 100644 --- a/tests/api/test_api_etudiants.py +++ b/tests/api/test_api_etudiants.py @@ -75,6 +75,8 @@ ETUDID = 1 NIP = "NIP2" INE = "INE1" +BUL_NB_FIELDS = 15 + def test_etudiants_courant(api_headers): """ @@ -508,7 +510,7 @@ def test_etudiant_bulletin_semestre(api_headers): ) assert r.status_code == 200 bulletin = r.json() - assert len(bulletin) == 14 # HARDCODED + assert len(bulletin) == BUL_NB_FIELDS assert verify_fields(bulletin, BULLETIN_FIELDS) is True assert isinstance(bulletin["version"], str) @@ -845,7 +847,7 @@ def test_etudiant_bulletin_semestre(api_headers): ) assert r.status_code == 200 bul = r.json() - assert len(bul) == 14 # HARDCODED + assert len(bul) == BUL_NB_FIELDS ######### Test code ine ######### r = requests.get( @@ -856,7 +858,7 @@ def test_etudiant_bulletin_semestre(api_headers): ) assert r.status_code == 200 bul = r.json() - assert len(bul) == 14 # HARDCODED + assert len(bul) == BUL_NB_FIELDS ######## Bulletin BUT court en pdf ######### r = requests.get( @@ -915,15 +917,15 @@ def test_etudiant_bulletin_semestre(api_headers): bul = GET( f"/etudiant/etudid/{ETUDID}/formsemestre/1/bulletin/short", headers=api_headers ) - assert len(bul) == 14 # HARDCODED + assert len(bul) == BUL_NB_FIELDS ######### Test code nip ######### bul = GET(f"/etudiant/nip/{NIP}/formsemestre/1/bulletin/short", headers=api_headers) - assert len(bul) == 14 # HARDCODED + assert len(bul) == BUL_NB_FIELDS ######### Test code ine ######### bul = GET(f"/etudiant/ine/{INE}/formsemestre/1/bulletin/short", headers=api_headers) - assert len(bul) == 14 # HARDCODED + assert len(bul) == BUL_NB_FIELDS ################### SHORT + PDF #####################