Editions formations BUT: meilleurs avertissements et possibilité de changer des associations en super-admin si décision saisies.

This commit is contained in:
Emmanuel Viennet 2025-02-19 16:38:22 +01:00
parent 7015789358
commit 845b0e9363
13 changed files with 125 additions and 48 deletions

View File

@ -209,26 +209,40 @@ def ue_set_parcours(ue_id: int):
return {"status": ok, "message": error_message} 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( @bp.route(
"/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>", "/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>",
defaults={"force": False},
methods=["POST"], methods=["POST"],
) )
@api_web_bp.route( @api_web_bp.route(
"/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>", "/formation/ue/<int:ue_id>/assoc_niveau/<int:niveau_id>",
defaults={"force": False},
methods=["POST"], methods=["POST"],
) )
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.EditFormation) @permission_required(Permission.EditFormation)
@as_json @as_json
def ue_assoc_niveau(ue_id: int, niveau_id: int): def ue_assoc_niveau(ue_id: int, niveau_id: int, force=False):
"""Associe l'UE au niveau de compétence.""" """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) query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id) query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404() ue: UniteEns = query.first_or_404()
niveau: ApcNiveau = ApcNiveau.get_or_404(niveau_id) 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 not ok:
if g.scodoc_dept: # "usage web" if g.scodoc_dept: # "usage web"
flash(error_message, "error") flash(error_message, "error")
@ -238,19 +252,31 @@ def ue_assoc_niveau(ue_id: int, niveau_id: int):
return {"status": 0} 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( @bp.route(
"/formation/ue/<int:ue_id>/desassoc_niveau", "/formation/ue/<int:ue_id>/desassoc_niveau",
defaults={"force": False},
methods=["POST"], methods=["POST"],
) )
@api_web_bp.route( @api_web_bp.route(
"/formation/ue/<int:ue_id>/desassoc_niveau", "/formation/ue/<int:ue_id>/desassoc_niveau",
defaults={"force": False},
methods=["POST"], methods=["POST"],
) )
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.EditFormation) @permission_required(Permission.EditFormation)
@as_json @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 """Désassocie cette UE de son niveau de compétence
(si elle n'est pas associée, ne fait rien). (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: if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id) query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404() 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 not ok:
if g.scodoc_dept: # "usage web" if g.scodoc_dept: # "usage web"
flash(error_message, "error") flash(error_message, "error")

View File

@ -96,11 +96,13 @@ def but_parcours_validated(etud: Identite, parcour_id: int | None) -> bool:
class EtudCursusBUT: class EtudCursusBUT:
"""L'état de l'étudiant dans son cursus BUT """L'état de l'étudiant dans son cursus BUT
Liste des niveaux validés/à valider 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): 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 # Vérifie que l'étudiant est bien inscrit à un sem. de cette formation
if formation.id not in ( if formation.id not in (
ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions
@ -142,10 +144,10 @@ class EtudCursusBUT:
if niveau is None: if niveau is None:
raise ScoValueError( raise ScoValueError(
f"""UE d'un RCUE ({ 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. }) non associée à un niveau de compétence.
Vérifiez la formation et les associations de ses UEs. Vérifiez la formation et les associations de ses UEs.
Étudiant {etud.nomprenom}. Étudiant {etud.html_link_fiche()}.
Formations concernées: <a href="{ Formations concernées: <a href="{
url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
formation_id=validation_rcue.ue1.formation_id, formation_id=validation_rcue.ue1.formation_id,
@ -156,7 +158,8 @@ class EtudCursusBUT:
formation_id=validation_rcue.ue2.formation_id, formation_id=validation_rcue.ue2.formation_id,
semestre_idx=validation_rcue.ue2.semestre_idx) semestre_idx=validation_rcue.ue2.semestre_idx)
}">{validation_rcue.ue2.acronyme}</a>. }">{validation_rcue.ue2.acronyme}</a>.
""" """,
safe=True,
) )
if not niveau.competence.id in self.validation_par_competence_et_annee: if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {} 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: if nb_ues_sans_parcours != nb_ues_tot:
H.append( 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 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 # Vérifie les niveaux de chaque parcours

View File

@ -1473,7 +1473,7 @@ class BonusTarbes(BonusIUTRennes1):
"""Calcul bonus optionnels (sport, culture), règle IUT de Tarbes. """Calcul bonus optionnels (sport, culture), règle IUT de Tarbes.
<ul> <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. La meilleure des notes obtenue est prise en compte, si elle est supérieure à 10/20.
</li> </li>
<li>Le trentième des points au dessus de 10 est ajouté à la moyenne des UE en BUT, <li>Le trentième des points au dessus de 10 est ajouté à la moyenne des UE en BUT,

View File

@ -464,7 +464,9 @@ class UniteEns(models.ScoDocModel):
> 0 > 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é. """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 Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas
de tronc commun). de tronc commun).
@ -473,7 +475,8 @@ class UniteEns(models.ScoDocModel):
Si niveau est None, désassocie. 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 Returns
- True if (de)association done, False on error. - 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", "La formation n'est pas associée à un référentiel de compétences",
) )
# UE utilisée dans des validations RCUE ? # 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 ( return (
False, False,
"UE utilisée dans un RCUE validé: son niveau ne peut plus être modifié", "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() db.session.commit()
# Invalidation du cache # Invalidation du cache
self.formation.invalidate_cached_sems() 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, "" return True, ""
def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]: def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]:

View File

@ -117,7 +117,7 @@ def do_formsemestre_archive(
dept_id=formsemestre.dept_id, dept_id=formsemestre.dept_id,
) )
# Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes) # 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 formsemestre, res, include_evaluations=True
) )
if table_html: if table_html:

View File

@ -58,7 +58,7 @@ from app.scodoc import (
) )
from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.html_sidebar import retreive_formsemestre_from_request
from app.scodoc.sco_bulletins import etud_descr_situation_semestre 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_formsemestre_validation import formsemestre_recap_parcours_table
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu 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(): if last_formsemestre and last_formsemestre.formation.is_apc():
try: try:
but_cursus = cursus_but.EtudCursusBUT(etud, last_formsemestre.formation) but_cursus = cursus_but.EtudCursusBUT(etud, last_formsemestre.formation)
except ScoValueError: except (ScoValueError, ScoNoReferentielCompetences):
but_cursus = None but_cursus = None
refcomp = last_formsemestre.formation.referentiel_competence refcomp = last_formsemestre.formation.referentiel_competence
if refcomp: if refcomp:

View File

@ -112,7 +112,7 @@ def formsemestre_recapcomplet(
visible_col_ids=visible_col_ids, 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, formsemestre,
filename=filename, filename=filename,
mode_jury=mode_jury, mode_jury=mode_jury,
@ -120,11 +120,17 @@ def formsemestre_recapcomplet(
selected_etudid=selected_etudid, selected_etudid=selected_etudid,
) )
H = [ H = []
# sco_formsemestre_status.formsemestre_status_head( if warnings:
# formsemestre_id=formsemestre_id 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: if len(formsemestre.inscriptions) > 0:
H.append( H.append(
f"""<form id="export_menu" name="f" method="get" action="{request.base_url}"> 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"): if tabformat not in ("html", "evals"):
raise ScoValueError("invalid table format") raise ScoValueError("invalid table format")
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
table_html, table, freq_codes_annuels = gen_formsemestre_recapcomplet_html_table( table_html, table, freq_codes_annuels, warnings = (
formsemestre, gen_formsemestre_recapcomplet_html_table(
res, formsemestre,
include_evaluations=(tabformat == "evals"), res,
mode_jury=mode_jury, include_evaluations=(tabformat == "evals"),
filename=filename, mode_jury=mode_jury,
selected_etudid=selected_etudid, 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( def _formsemestre_recapcomplet_to_file(
@ -473,7 +481,7 @@ def gen_formsemestre_recapcomplet_html_table(
mode_jury=False, mode_jury=False,
filename="", filename="",
selected_etudid=None, selected_etudid=None,
) -> tuple[str, TableRecap, collections.Counter]: ) -> tuple[str, TableRecap, collections.Counter, list[str]]:
"""Construit table recap pour le BUT """Construit table recap pour le BUT
Cache le résultat pour le semestre. Cache le résultat pour le semestre.
Note: on cache le HTML et non l'objet Table. Note: on cache le HTML et non l'objet Table.
@ -508,11 +516,12 @@ def gen_formsemestre_recapcomplet_html_table(
freq_codes_annuels = ( freq_codes_annuels = (
table.freq_codes_annuels if hasattr(table, "freq_codes_annuels") else None 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: 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( def _gen_formsemestre_recapcomplet_table(

View File

@ -65,4 +65,10 @@ div.gt_caption {
.dt-scroll-foot { .dt-scroll-foot {
overflow: visible !important; overflow: visible !important;
}
div.table-warnings {
background-color: yellow;
color: darkred;
max-width: 100%;
} }

View File

@ -1284,6 +1284,10 @@ div.sco_box_title {
background-color: rgb(209, 255, 214); background-color: rgb(209, 255, 214);
} }
div.sco_box.sco_dashed {
border: 1px dashed red;
}
div.vertical_spacing_but { div.vertical_spacing_but {
margin-top: 12px; margin-top: 12px;
} }

View File

@ -24,6 +24,7 @@ from app.scodoc.codes_cursus import (
BUT_BARRE_RCUE, BUT_BARRE_RCUE,
BUT_RCUE_SUFFISANT, BUT_RCUE_SUFFISANT,
) )
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.tables.recap import RowRecap, TableRecap from app.tables.recap import RowRecap, TableRecap
@ -201,9 +202,13 @@ class TableJury(TableRecap):
self.group_titles[group] = f"Compétences {annee}" self.group_titles[group] = f"Compétences {annee}"
for row in self.rows: for row in self.rows:
etud = row.etud etud = row.etud
cursus_dict = cursus_but.EtudCursusBUT( try:
etud, self.res.formsemestre.formation cursus_dict = cursus_but.EtudCursusBUT(
).to_dict() etud, self.res.formsemestre.formation
).to_dict()
except ScoValueError as exc:
cursus_dict = {}
self.warnings.append(exc.args[0])
first = True first = True
for competence_id in cursus_dict: for competence_id in cursus_dict:
for annee in ("BUT1", "BUT2", "BUT3"): for annee in ("BUT1", "BUT2", "BUT3"):

View File

@ -117,6 +117,8 @@ class Table(Element):
# #
self.caption = caption self.caption = caption
self.origin = origin self.origin = origin
self.warnings: list[str] = []
"liste d'avertissements rencontrés en construisant la table"
def _prepare(self): def _prepare(self):
"""Prepare the table before generation: """Prepare the table before generation:

View File

@ -124,6 +124,17 @@ Choisissez un parcours...
</div> </div>
{% endif %} {% 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 %} {% if parcour %}
<div class="help"> <div class="help">
@ -150,7 +161,7 @@ Choisissez un parcours...
function ue_assoc_niveau(event, niveau_id) { function ue_assoc_niveau(event, niveau_id) {
let ue_id = event.target.value; let ue_id = event.target.value;
let url = ""; let url = "";
let must_reload = false; let force = document.getElementById('force_modification').checked;
if (ue_id == "") { if (ue_id == "") {
/* Dé-associe */ /* Dé-associe */
ue_id = event.target.dataset.ue_id; 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); url = desassoc_url.replace('11111', ue_id);
must_reload=true; if (force) {
url += '/force';
}
} else { } else {
const assoc_url = '{{ const assoc_url = '{{
url_for( url_for(
@ -172,6 +185,9 @@ function ue_assoc_niveau(event, niveau_id) {
) )
}}'; }}';
url = assoc_url.replace('11111', ue_id).replace('22222', niveau_id); url = assoc_url.replace('11111', ue_id).replace('22222', niveau_id);
if (force) {
url += '/force';
}
} }
fetch(url, { fetch(url, {
method: 'POST', method: 'POST',

View File

@ -75,6 +75,8 @@ ETUDID = 1
NIP = "NIP2" NIP = "NIP2"
INE = "INE1" INE = "INE1"
BUL_NB_FIELDS = 15
def test_etudiants_courant(api_headers): def test_etudiants_courant(api_headers):
""" """
@ -508,7 +510,7 @@ def test_etudiant_bulletin_semestre(api_headers):
) )
assert r.status_code == 200 assert r.status_code == 200
bulletin = r.json() bulletin = r.json()
assert len(bulletin) == 14 # HARDCODED assert len(bulletin) == BUL_NB_FIELDS
assert verify_fields(bulletin, BULLETIN_FIELDS) is True assert verify_fields(bulletin, BULLETIN_FIELDS) is True
assert isinstance(bulletin["version"], str) assert isinstance(bulletin["version"], str)
@ -845,7 +847,7 @@ def test_etudiant_bulletin_semestre(api_headers):
) )
assert r.status_code == 200 assert r.status_code == 200
bul = r.json() bul = r.json()
assert len(bul) == 14 # HARDCODED assert len(bul) == BUL_NB_FIELDS
######### Test code ine ######### ######### Test code ine #########
r = requests.get( r = requests.get(
@ -856,7 +858,7 @@ def test_etudiant_bulletin_semestre(api_headers):
) )
assert r.status_code == 200 assert r.status_code == 200
bul = r.json() bul = r.json()
assert len(bul) == 14 # HARDCODED assert len(bul) == BUL_NB_FIELDS
######## Bulletin BUT court en pdf ######### ######## Bulletin BUT court en pdf #########
r = requests.get( r = requests.get(
@ -915,15 +917,15 @@ def test_etudiant_bulletin_semestre(api_headers):
bul = GET( bul = GET(
f"/etudiant/etudid/{ETUDID}/formsemestre/1/bulletin/short", headers=api_headers 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 ######### ######### Test code nip #########
bul = GET(f"/etudiant/nip/{NIP}/formsemestre/1/bulletin/short", headers=api_headers) 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 ######### ######### Test code ine #########
bul = GET(f"/etudiant/ine/{INE}/formsemestre/1/bulletin/short", headers=api_headers) 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 ##################### ################### SHORT + PDF #####################