diff --git a/app/api/formations.py b/app/api/formations.py index def1c12e7ef7e6f0006dd50e247b16597874018f..815233d3e422558b0139423ca402fc790b9552a0 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/<int:ue_id>/assoc_niveau/<int:niveau_id>/force", + defaults={"force": True}, + methods=["POST"], +) +@api_web_bp.route( + "/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>/force", + defaults={"force": True}, + methods=["POST"], +) @bp.route( "/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>", + defaults={"force": False}, methods=["POST"], ) @api_web_bp.route( "/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>", + 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/<int:ue_id>/desassoc_niveau/force", + defaults={"force": True}, + methods=["POST"], +) +@api_web_bp.route( + "/formation/ue/<int:ue_id>/desassoc_niveau/force", + defaults={"force": True}, + methods=["POST"], +) @bp.route( "/formation/ue/<int:ue_id>/desassoc_niveau", + defaults={"force": False}, methods=["POST"], ) @api_web_bp.route( "/formation/ue/<int:ue_id>/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 3785f694bc86cf0472ec868614bece654b4ffe88..a31f34003f8aa9dffcdacb7c84f6123217d14f3c 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: <a href="{ url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=validation_rcue.ue1.formation_id, @@ -156,7 +158,8 @@ class EtudCursusBUT: formation_id=validation_rcue.ue2.formation_id, semestre_idx=validation_rcue.ue2.semestre_idx) }">{validation_rcue.ue2.acronyme}</a>. - """ + """, + 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 a480b2109673b62109b31b6e636565388e6b8b12..7c9f33550b3d0664d0fe30ad01e7edccc718b25e 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. <ul> - <li>Les étudiants opeuvent suivre un ou plusieurs activités optionnelles notées. + <li>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. </li> <li>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 6c13402479c4efefe3ea9f536350f3138e8d44b1..7cf25bc63238a9511339125ec69a7061087ee6c6 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 892e9438a5f1fc5b354dbf14770c426d27d2f029..3e4b54d2081cefc7bdbadf22e7cc618b7498e0dc 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 0f70477f4c3abf6bd2527c0fdbec7364eb4856cd..b925716ab2c6d6d4e785f88e0841618c7e485a2d 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 55237e5e39cf0745e5a4d522516b0c7ba26569cf..8e9f93403c7f6425ee8a79dcf5bd491076fc4fa9 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""" + <div class="sco_box table-warnings"> + <div class="sco_box_title">Avertissements</div> + <ul><li> + {'</li><li>'.join(warnings)} + </li></ul> + </div>""" + ) if len(formsemestre.inscriptions) > 0: H.append( f"""<form id="export_menu" name="f" method="get" action="{request.base_url}"> @@ -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 850bbe1ced68307cb39f75beac038c858a2d76f0..2baa6f6e3959d55bb11e73b39a7ded2ee2137a65 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 7e072b6080f23a67b59049889dcc5f5b23ca854a..28975ef6aac61f7c7ba47fdbf7916876041d8d8b 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 8189f978c4f804b3125a87cf23aa65923f645db6..dbd4a8a3e4c2138bbe752acd12788d2ce1785f17 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 466ac8e8a97cde8065ba6dbbf6e6aa5091651a49..2ae8f89f16b975a58cbf977401863280a1cd3124 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 1b0f239689656cf2df7a1d35d51b5fe624b89340..92de363da22a3414754d91e0eeddf98a9e3dd78b 100644 --- a/app/templates/but/parcour_formation.j2 +++ b/app/templates/but/parcour_formation.j2 @@ -124,6 +124,17 @@ Choisissez un parcours... </div> {% endif %} +{% if current_user.is_administrator() %} +<div class="sco_box sco_dashed"> +<b>Vous êtes super-administrateur.</b> +<div> + <input type="checkbox" id="force_modification" name="force_modification"> + <label for="force_modification">forcer modification même si décisions de jury enregistrées (dangereux !)</label> +</div> +</div> +{% endif %} + + {% if parcour %} <div class="help"> @@ -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 6ae352c4b7758985352cfc32ee10c00a920362d4..32b16112fee58caf0760a567a08d849e558c775a 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 #####################