diff --git a/app/__init__.py b/app/__init__.py
index 6fb6cf052..51e122cd5 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -550,3 +550,22 @@ def scodoc_flash_status_messages():
f"Mode test: mails redirigés vers {email_test_mode_address}",
category="warning",
)
+
+
+def critical_error(msg):
+ """Handle a critical error: flush all caches, display message to the user"""
+ import app.scodoc.sco_utils as scu
+
+ log(f"\n*** CRITICAL ERROR: {msg}")
+ send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
+ clear_scodoc_cache()
+ raise ScoValueError(
+ f"""
+ Une erreur est survenue.
+
+ Si le problème persiste, merci de contacter le support ScoDoc via
+ {scu.SCO_DISCORD_ASSISTANCE}
+
+ {msg}
+ """
+ )
diff --git a/app/api/partitions.py b/app/api/partitions.py
index 699f18e71..42307656b 100644
--- a/app/api/partitions.py
+++ b/app/api/partitions.py
@@ -19,6 +19,7 @@ from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition
from app.models.groups import group_membership
from app.scodoc import sco_cache
+from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
@@ -170,24 +171,15 @@ def set_etud_group(etudid: int, group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
+ if not group.partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
if etud.id not in {e.id for e in group.partition.formsemestre.etuds}:
return json_error(404, "etud non inscrit au formsemestre du groupe")
- groups = (
- GroupDescr.query.filter_by(partition_id=group.partition.id)
- .join(group_membership)
- .filter_by(etudid=etudid)
+
+ sco_groups.change_etud_group_in_partition(
+ etudid, group_id, group.partition.to_dict()
)
- ok = False
- for other_group in groups:
- if other_group.id == group_id:
- ok = True
- else:
- other_group.etuds.remove(etud)
- if not ok:
- group.etuds.append(etud)
- log(f"set_etud_group({etud}, {group})")
- db.session.commit()
- sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
+
return jsonify({"group_id": group_id, "etudid": etudid})
@@ -207,6 +199,8 @@ def group_remove_etud(group_id: int, etudid: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
+ if not group.partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
if etud in group.etuds:
group.etuds.remove(etud)
db.session.commit()
@@ -232,6 +226,8 @@ def partition_remove_etud(partition_id: int, etudid: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition = query.first_or_404()
+ if not partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
groups = (
GroupDescr.query.filter_by(partition_id=partition_id)
.join(group_membership)
@@ -262,8 +258,10 @@ def group_create(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
+ if not partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
if not partition.groups_editable:
- return json_error(404, "partition non editable")
+ return json_error(403, "partition non editable")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is None:
@@ -294,8 +292,10 @@ def group_delete(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group: GroupDescr = query.first_or_404()
+ if not group.partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable:
- return json_error(404, "partition non editable")
+ return json_error(403, "partition non editable")
formsemestre_id = group.partition.formsemestre_id
log(f"deleting {group}")
db.session.delete(group)
@@ -318,8 +318,10 @@ def group_edit(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group: GroupDescr = query.first_or_404()
+ if not group.partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable:
- return json_error(404, "partition non editable")
+ return json_error(403, "partition non editable")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is not None:
@@ -358,6 +360,8 @@ def partition_create(formsemestre_id: int):
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
+ if not formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
data = request.get_json(force=True) # may raise 400 Bad Request
partition_name = data.get("partition_name")
if partition_name is None:
@@ -406,6 +410,8 @@ def formsemestre_order_partitions(formsemestre_id: int):
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
+ if not formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
partition_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(partition_ids, int) and not all(
isinstance(x, int) for x in partition_ids
@@ -443,6 +449,8 @@ def partition_order_groups(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
+ if not partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
group_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(group_ids, int) and not all(
isinstance(x, int) for x in group_ids
@@ -484,6 +492,8 @@ def partition_edit(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
+ if not partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
data = request.get_json(force=True) # may raise 400 Bad Request
modified = False
partition_name = data.get("partition_name")
@@ -542,6 +552,8 @@ def partition_delete(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
+ if not partition.formsemestre.etat:
+ return json_error(403, "formsemestre verrouillé")
if not partition.partition_name:
return json_error(404, "ne peut pas supprimer la partition par défaut")
is_parcours = partition.is_parcours()
diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py
index 15ac1fa37..bc72ae860 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,117 @@ 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
+ and (
+ 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 parcours à 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
+ ).get(validation_rcue.annee())
+ # 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/but/jury_but.py b/app/but/jury_but.py
index c3e5b856a..244989edd 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -64,7 +64,7 @@ import re
from typing import Union
import numpy as np
-from flask import g, url_for
+from flask import flash, g, url_for
from app import db
from app import log
@@ -554,7 +554,6 @@ class DecisionsProposeesAnnee(DecisionsProposees):
"""Liste des regroupements d'UE à considérer cette année.
On peut avoir un RCUE à cheval sur plusieurs années (redoublants avec UE capitalisées).
Si on n'a pas les deux semestres, aucun RCUE.
- Raises ScoValueError s'il y a des UE sans RCUE. <= ??? XXX
"""
if self.formsemestre_pair is None or self.formsemestre_impair is None:
return []
@@ -570,6 +569,10 @@ class DecisionsProposeesAnnee(DecisionsProposees):
not in CODES_UE_VALIDES
):
continue # ignore cette UE antérieure non capitalisée
+ # et l'UE impaire doit être actuellement meilleure que
+ # celle éventuellement capitalisée
+ if self.decisions_ues[ue_impair.id].ue_status["is_capitalized"]:
+ continue # ignore cette UE car capitalisée et actuelle moins bonne
if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
rcue = RegroupementCoherentUE(
self.etud,
@@ -690,20 +693,20 @@ class DecisionsProposeesAnnee(DecisionsProposees):
db.session.commit()
- def record(self, code: str, no_overwrite=False):
+ def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
Si no_overwrite, ne fait rien si un code est déjà enregistré.
Si l'étudiant est DEM ou DEF, ne fait rien.
"""
if self.inscription_etat != scu.INSCRIT:
- return
+ return False
if code and not code in self.codes:
raise ScoValueError(
f"code annee {html.escape(code)} invalide pour formsemestre {html.escape(self.formsemestre)}"
)
if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True
- return # no change
+ return False # no change
if self.validation:
db.session.delete(self.validation)
db.session.commit()
@@ -743,9 +746,10 @@ class DecisionsProposeesAnnee(DecisionsProposees):
next_semestre_id,
)
- self.recorded = True
db.session.commit()
+ self.recorded = True
self.invalidate_formsemestre_cache()
+ return True
def invalidate_formsemestre_cache(self):
"invalide le résultats des deux formsemestres"
@@ -756,13 +760,20 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if self.formsemestre_pair is not None:
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
- def record_all(self, no_overwrite: bool = True):
+ def record_all(
+ self, no_overwrite: bool = True, only_validantes: bool = False
+ ) -> bool:
"""Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
et sont donc en mode "automatique".
- Si "à cheval", ne modifie pas les codes UE de l'année scolaire précédente.
- Pour les RCUE: n'enregistre que si la nouvelle décision est plus favorable que l'ancienne.
+
+ Si only_validantes, n'enregistre que des décisions "validantes" de droit: ADM ou CMP.
+
+ Return: True si au moins un code modifié et enregistré.
"""
- # Toujours valider dans l'ordre UE, RCUE, Année:
+ modif = False
+ # Toujours valider dans l'ordre UE, RCUE, Année
annee_scolaire = self.formsemestre.annee_scolaire()
# UEs
for dec_ue in self.decisions_ues.values():
@@ -771,25 +782,40 @@ class DecisionsProposeesAnnee(DecisionsProposees):
) and dec_ue.formsemestre.annee_scolaire() == annee_scolaire:
# rappel: le code par défaut est en tête
code = dec_ue.codes[0] if dec_ue.codes else None
- # enregistre le code jury seulement s'il n'y a pas déjà de code
- # (no_overwrite=True) sauf en mode test yaml
- dec_ue.record(code, no_overwrite=no_overwrite)
- # RCUE : enregistre seulement si pas déjà validé "mieux"
+ if (not only_validantes) or code in sco_codes.CODES_UE_VALIDES_DE_DROIT:
+ # enregistre le code jury seulement s'il n'y a pas déjà de code
+ # (no_overwrite=True) sauf en mode test yaml
+ modif |= dec_ue.record(code, no_overwrite=no_overwrite)
+ # RCUE :
for dec_rcue in self.decisions_rcue_by_niveau.values():
code = dec_rcue.codes[0] if dec_rcue.codes else None
- if (not dec_rcue.recorded) and (
- (not dec_rcue.validation)
- or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0)
- < BUT_CODES_ORDERED.get(code, 0)
+ if (
+ (not dec_rcue.recorded)
+ and ( # enregistre seulement si pas déjà validé "mieux"
+ (not dec_rcue.validation)
+ or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0)
+ < BUT_CODES_ORDERED.get(code, 0)
+ )
+ and ( # décision validante de droit ?
+ (
+ (not only_validantes)
+ or code in sco_codes.CODES_RCUE_VALIDES_DE_DROIT
+ )
+ )
):
- dec_rcue.record(code, no_overwrite=no_overwrite)
+ modif |= dec_rcue.record(code, no_overwrite=no_overwrite)
# Année:
if not self.recorded:
# rappel: le code par défaut est en tête
code = self.codes[0] if self.codes else None
# enregistre le code jury seulement s'il n'y a pas déjà de code
# (no_overwrite=True) sauf en mode test yaml
- self.record(code, no_overwrite=no_overwrite)
+ if (
+ not only_validantes
+ ) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT:
+ modif |= self.record(code, no_overwrite=no_overwrite)
+
+ return modif
def erase(self, only_one_sem=False):
"""Efface les décisions de jury de cet étudiant
@@ -1002,23 +1028,23 @@ class DecisionsProposeesRCUE(DecisionsProposees):
return f"""<{self.__class__.__name__} rcue={self.rcue} valid={self.code_valide
} codes={self.codes} explanation={self.explanation}"""
- def record(self, code: str, no_overwrite=False):
+ def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code RCUE.
Note:
- si le RCUE est ADJ, les UE non validées sont passées à ADJ
XXX on pourra imposer ici d'autres règles de cohérence
"""
if self.rcue is None:
- return # pas de RCUE a enregistrer
+ return False # pas de RCUE a enregistrer
if self.inscription_etat != scu.INSCRIT:
- return
+ return False
if code and not code in self.codes:
raise ScoValueError(
f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
)
if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True
- return # no change
+ return False # no change
parcours_id = self.parcour.id if self.parcour is not None else None
if self.validation:
db.session.delete(self.validation)
@@ -1051,7 +1077,13 @@ class DecisionsProposeesRCUE(DecisionsProposees):
dec_ue = deca.decisions_ues.get(ue_id)
if dec_ue and dec_ue.code_valide not in CODES_UE_VALIDES:
log(f"rcue.record: force ADJR sur {dec_ue}")
- dec_ue.record("ADJR")
+ flash(
+ f"""UEs du RCUE "{dec_ue.ue.niveau_competence.competence.titre}" passées en ADJR"""
+ )
+ dec_ue.record(sco_codes.ADJR)
+
+ # Valide les niveaux inférieurs de la compétence (code ADSUP)
+ # TODO
if self.rcue.formsemestre_1 is not None:
sco_cache.invalidate_formsemestre(
@@ -1063,6 +1095,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
)
self.code_valide = code # mise à jour état
self.recorded = True
+ return True
def erase(self):
"""Efface la décision de jury de cet étudiant pour cet RCUE"""
@@ -1194,9 +1227,10 @@ class DecisionsProposeesUE(DecisionsProposees):
self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
self.explanation = "notes insuffisantes"
- def record(self, code: str, no_overwrite=False):
+ def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code jury pour cette UE.
Si no_overwrite, n'enregistre pas s'il y a déjà un code.
+ Return: True si code enregistré (modifié)
"""
if code and not code in self.codes:
raise ScoValueError(
@@ -1204,7 +1238,7 @@ class DecisionsProposeesUE(DecisionsProposees):
)
if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True
- return # no change
+ return False # no change
self.erase()
if code is None:
self.validation = None
@@ -1235,6 +1269,7 @@ class DecisionsProposeesUE(DecisionsProposees):
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)
self.code_valide = code # mise à jour
self.recorded = True
+ return True
def erase(self):
"""Efface la décision de jury de cet étudiant pour cette UE"""
diff --git a/app/but/jury_but_recap.py b/app/but/jury_but_recap.py
index 386579575..809294408 100644
--- a/app/but/jury_but_recap.py
+++ b/app/but/jury_but_recap.py
@@ -284,6 +284,10 @@ class RowCollector:
self["_nom_disp_order"] = etud.sort_key
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court")
+ self["_nom_short_data"] = {
+ "etudid": etud.id,
+ "nomprenom": etud.nomprenom,
+ }
if with_links:
self["_nom_short_order"] = etud.sort_key
self["_nom_short_target"] = url_for(
@@ -368,10 +372,6 @@ class RowCollector:
+ ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
"col_rcue col_rcues_validables" + klass,
)
- self["_rcues_validables_data"] = {
- "etudid": deca.etud.id,
- "nomprenom": deca.etud.nomprenom,
- }
if len(deca.rcues_annee) > 0:
# permet un tri par nb de niveaux validables + moyenne gen indicative S_pair
if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:
diff --git a/app/but/jury_but_validation_auto.py b/app/but/jury_but_validation_auto.py
index d5e308ab9..674389b54 100644
--- a/app/but/jury_but_validation_auto.py
+++ b/app/but/jury_but_validation_auto.py
@@ -18,29 +18,29 @@ from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True
) -> int:
- """Calcul automatique des décisions de jury sur une année BUT.
- Ne modifie jamais de décisions de l'année scolaire précédente, même
+ """Calcul automatique des décisions de jury sur une "année" BUT.
+
+ - N'enregistre jamais de décisions de l'année scolaire précédente, même
si on a des RCUE "à cheval".
- Normalement, only_adm est True et on n'enregistre que les décisions ADM (de droit).
- Si only_adm est faux, on enregistre la première décision proposée par ScoDoc
- (mode à n'utiliser que pour les tests)
+ - Ne modifie jamais de décisions déjà enregistrées (sauf si no_overwrite est faux,
+ ce qui est utilisé pour certains tests unitaires).
+ - Normalement, only_adm est True et on n'enregistre que les décisions validantes
+ de droit: ADM ou CMP.
+ En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
+ (mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
- Si no_overwrite est vrai (défaut), ne ré-écrit jamais les codes déjà enregistrés
- (utiliser faux pour certains tests)
-
- Returns: nombre d'étudiants "admis"
+ Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
"""
if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT")
- nb_admis = 0
+ nb_etud_modif = 0
with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
- if deca.admis: # année réussie
- nb_admis += 1
- if deca.admis or not only_adm:
- deca.record_all(no_overwrite=no_overwrite)
+ nb_etud_modif += deca.record_all(
+ no_overwrite=no_overwrite, only_validantes=only_adm
+ )
db.session.commit()
- return nb_admis
+ return nb_etud_modif
diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py
index 3140bd02e..acb6b695b 100644
--- a/app/but/jury_but_view.py
+++ b/app/but/jury_but_view.py
@@ -196,7 +196,7 @@ def _gen_but_niveau_ue(
UE en cours
{ "sans notes" if np.isnan(dec_ue.moy_ue)
else
- ("avec moyenne" + scu.fmt_note(dec_ue.moy_ue))
+ ("avec moyenne " + scu.fmt_note(dec_ue.moy_ue) + "")
}
- Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
+
Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
+
"""
else:
scoplement = ""
diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index dfced34cc..1fccbda15 100644
--- a/app/comp/moy_mod.py
+++ b/app/comp/moy_mod.py
@@ -39,6 +39,7 @@ from dataclasses import dataclass
import numpy as np
import pandas as pd
+import app
from app import db
from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
@@ -484,7 +485,8 @@ class ModuleImplResultsClassic(ModuleImplResults):
if nb_etuds == 0:
return pd.Series()
evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1)
- assert evals_coefs.shape == (nb_evals,)
+ if evals_coefs.shape != (nb_evals,):
+ app.critical_error("compute_module_moy: vals_coefs.shape != nb_evals")
evals_notes_20 = self.get_eval_notes_sur_20(modimpl)
# Les coefs des évals pour chaque étudiant: là où il a des notes
# non neutralisées
diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index 902d24050..7632ec28b 100644
--- a/app/comp/res_common.py
+++ b/app/comp/res_common.py
@@ -543,6 +543,10 @@ class ResultatsSemestre(ResultatsCache):
formsemestre_id=self.formsemestre.id,
etudid=etudid,
)
+ row["_nom_short_data"] = {
+ "etudid": etud.id,
+ "nomprenom": etud.nomprenom,
+ }
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
row["_nom_disp_target"] = row["_nom_short_target"]
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
@@ -905,7 +909,7 @@ class ResultatsSemestre(ResultatsCache):
}
first = True
for i, cid in enumerate(fields):
- titles[f"_{cid}_col_order"] = 10000 + i # tout à droite
+ titles[f"_{cid}_col_order"] = 100000 + i # tout à droite
if first:
titles[f"_{cid}_class"] = "admission admission_first"
first = False
diff --git a/app/decorators.py b/app/decorators.py
index 83441275e..5338828f4 100644
--- a/app/decorators.py
+++ b/app/decorators.py
@@ -16,6 +16,7 @@ import flask_login
import app
from app.auth.models import User
import app.scodoc.sco_utils as scu
+from app.scodoc.sco_exceptions import ScoValueError
class ZUser(object):
@@ -180,19 +181,24 @@ def scodoc7func(func):
else:
arg_names = argspec.args
for arg_name in arg_names: # pour chaque arg de la fonction vue
- if arg_name == "REQUEST": # ne devrait plus arriver !
- # debug check, TODO remove after tests
- raise ValueError("invalid REQUEST parameter !")
- else:
- # peut produire une KeyError s'il manque un argument attendu:
- v = req_args[arg_name]
- # try to convert all arguments to INTEGERS
- # necessary for db ids and boolean values
- try:
- v = int(v)
- except (ValueError, TypeError):
- pass
- pos_arg_values.append(v)
+ # peut produire une KeyError s'il manque un argument attendu:
+ v = req_args[arg_name]
+ # try to convert all arguments to INTEGERS
+ # necessary for db ids and boolean values
+ try:
+ v = int(v) if v else v
+ except (ValueError, TypeError) as exc:
+ if arg_name in {
+ "etudid",
+ "formation_id",
+ "formsemestre_id",
+ "module_id",
+ "moduleimpl_id",
+ "partition_id",
+ "ue_id",
+ }:
+ raise ScoValueError("page introuvable (id invalide)") from exc
+ pos_arg_values.append(v)
# current_app.logger.info("pos_arg_values=%s" % pos_arg_values)
# current_app.logger.info("req_args=%s" % req_args)
# Add keyword arguments
diff --git a/app/forms/main/config_apo.py b/app/forms/main/config_apo.py
index f607822c1..34e04d8ea 100644
--- a/app/forms/main/config_apo.py
+++ b/app/forms/main/config_apo.py
@@ -63,6 +63,7 @@ class CodesDecisionsForm(FlaskForm):
ABL = _build_code_field("ABL")
ADC = _build_code_field("ADC")
ADJ = _build_code_field("ADJ")
+ ADJR = _build_code_field("ADJR")
ADM = _build_code_field("ADM")
AJ = _build_code_field("AJ")
ATB = _build_code_field("ATB")
diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py
index 647e6ee89..425ff1923 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 52826003e..ccef89cd4 100644
--- a/app/models/but_validations.py
+++ b/app/models/but_validations.py
@@ -71,11 +71,22 @@ class ApcValidationRCUE(db.Model):
enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}"""
+ def annee(self) -> str:
+ """l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
+ niveau = self.niveau()
+ return niveau.annee if niveau else None
+
def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE."""
# 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/models/formations.py b/app/models/formations.py
index 36e356472..986ef7e74 100644
--- a/app/models/formations.py
+++ b/app/models/formations.py
@@ -55,7 +55,8 @@ class Formation(db.Model):
modules = db.relationship("Module", lazy="dynamic", backref="formation")
def __repr__(self):
- return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>"
+ return f"""<{self.__class__.__name__}(id={self.id}, dept_id={
+ self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>"""
def to_html(self) -> str:
"titre complet pour affichage"
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 3c457d980..bafad116e 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -63,51 +63,51 @@ class FormSemestre(db.Model):
"False si verrouillé"
modalite = db.Column(
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
- ) # "FI", "FAP", "FC", ...
- # gestion compensation sem DUT:
+ )
+ "Modalité de formation: 'FI', 'FAP', 'FC', ..."
gestion_compensation = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
- # ne publie pas le bulletin XML ou JSON:
+ "gestion compensation sem DUT (inutilisé en APC)"
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
- # Bloque le calcul des moyennes (générale et d'UE)
+ "ne publie pas le bulletin XML ou JSON"
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
- # Bloque le calcul de la moyenne générale (utile pour BUT)
+ "Bloque le calcul des moyennes (générale et d'UE)"
block_moyenne_generale = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
- # semestres decales (pour gestion jurys):
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
- # couleur fond bulletins HTML:
+ "Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
bul_bgcolor = db.Column(
db.String(SHORT_STR_LEN),
default="white",
server_default="white",
nullable=False,
)
- # autorise resp. a modifier semestre:
+ "couleur fond bulletins HTML"
resp_can_edit = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
- # autorise resp. a modifier slt les enseignants:
+ "autorise resp. à modifier le formsemestre"
resp_can_change_ens = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true"
)
- # autorise les ens a creer des evals:
+ "autorise resp. a modifier slt les enseignants"
ens_can_edit_eval = db.Column(
db.Boolean(), nullable=False, default=False, server_default="False"
)
- # code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'
+ "autorise les enseignants à créer des évals dans leurs modimpls"
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
- # code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'
+ "code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
elt_annee_apo = db.Column(db.Text())
+ "code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
# Relations:
etapes = db.relationship(
diff --git a/app/models/groups.py b/app/models/groups.py
index 4fd25a940..6fafa234b 100644
--- a/app/models/groups.py
+++ b/app/models/groups.py
@@ -87,6 +87,7 @@ class Partition(db.Model):
def to_dict(self, with_groups=False) -> dict:
"""as a dict, with or without groups"""
d = dict(self.__dict__)
+ d["partition_id"] = self.id
d.pop("_sa_instance_state", None)
d.pop("formsemestre", None)
diff --git a/app/models/ues.py b/app/models/ues.py
index 387fc8d28..596e0bef6 100644
--- a/app/models/ues.py
+++ b/app/models/ues.py
@@ -111,6 +111,7 @@ class UniteEns(db.Model):
e["ects"] = e["ects"]
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None
+ e["parcour"] = self.parcour.to_dict() if self.parcour else None
if with_module_ue_coefs:
if convert_objects:
e["module_ue_coefs"] = [
@@ -219,6 +220,8 @@ class UniteEns(db.Model):
db.session.add(self)
db.session.commit()
+ # Invalidation du cache
+ self.formation.invalidate_cached_sems()
log(f"ue.set_niveau_competence( {self}, {niveau} )")
def set_parcour(self, parcour: ApcParcours):
@@ -246,6 +249,8 @@ class UniteEns(db.Model):
self.niveau_competence = None
db.session.add(self)
db.session.commit()
+ # Invalidation du cache
+ self.formation.invalidate_cached_sems()
log(f"ue.set_parcour( {self}, {parcour} )")
diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py
index 7b0c79970..502027529 100644
--- a/app/pe/pe_jurype.py
+++ b/app/pe/pe_jurype.py
@@ -459,8 +459,7 @@ class JuryPE(object):
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
(_, parcours) = sco_report.get_codeparcoursetud(etud)
if (
- len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values()))
- > 0
+ len(sco_codes_parcours.CODES_SEM_REO & set(parcours.values())) > 0
): # Eliminé car NAR apparait dans le parcours
reponse = True
if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2:
@@ -563,9 +562,8 @@ class JuryPE(object):
dec = nt.get_etud_decision_sem(
etudid
) # quelle est la décision du jury ?
- if dec and dec["code"] in list(
- sco_codes_parcours.CODES_SEM_VALIDES.keys()
- ): # isinstance( sesMoyennes[i+1], float) and
+ if dec and (dec["code"] in sco_codes_parcours.CODES_SEM_VALIDES):
+ # isinstance( sesMoyennes[i+1], float) and
# mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide"
leFid = sem["formsemestre_id"]
else:
diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py
index 8e2f29145..e47177d64 100644
--- a/app/scodoc/sco_bulletins.py
+++ b/app/scodoc/sco_bulletins.py
@@ -1084,7 +1084,7 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
recipients = [recipient_addr]
sender = sco_preferences.get_preference("email_from_addr", formsemestre_id)
if copy_addr:
- bcc = copy_addr.strip()
+ bcc = copy_addr.strip().split(",")
else:
bcc = ""
@@ -1094,7 +1094,7 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
subject,
sender,
recipients,
- bcc=[bcc],
+ bcc=bcc,
text_body=hea,
attachments=[
{"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata}
diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py
index 21b2b0c87..df5484fb0 100644
--- a/app/scodoc/sco_codes_parcours.py
+++ b/app/scodoc/sco_codes_parcours.py
@@ -187,16 +187,23 @@ CODES_EXPL = {
# Les codes de semestres:
CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT}
-CODES_SEM_VALIDES = {ADM: True, ADC: True, ADJ: True} # semestre validé
-CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente
+CODES_SEM_VALIDES_DE_DROIT = {ADM, ADC}
+CODES_SEM_VALIDES = CODES_SEM_VALIDES_DE_DROIT | {ADJ} # semestre validé
+CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente
-CODES_SEM_REO = {NAR: 1} # reorientation
+CODES_SEM_REO = {NAR} # reorientation
+
+CODES_UE_VALIDES_DE_DROIT = {ADM, CMP} # validation "de droit"
+CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR}
+"UE validée"
+
+CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP}
+CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ}
+"Niveau RCUE validé"
-CODES_UE_VALIDES = {ADM: True, CMP: True, ADJ: True, ADJR: True} # UE validée
-CODES_RCUE_VALIDES = CODES_UE_VALIDES # Niveau RCUE validé
# Pour le BUT:
+CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD}
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
-CODES_RCUE = {ADM, AJ, CMP}
BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE
BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE
@@ -226,17 +233,17 @@ BUT_CODES_ORDERED = {
def code_semestre_validant(code: str) -> bool:
"Vrai si ce CODE entraine la validation du semestre"
- return CODES_SEM_VALIDES.get(code, False)
+ return code in CODES_SEM_VALIDES
def code_semestre_attente(code: str) -> bool:
"Vrai si ce CODE est un code d'attente (semestre validable plus tard par jury ou compensation)"
- return CODES_SEM_ATTENTES.get(code, False)
+ return code in CODES_SEM_ATTENTES
def code_ue_validant(code: str) -> bool:
"Vrai si ce code d'UE est validant (ie attribue les ECTS)"
- return CODES_UE_VALIDES.get(code, False)
+ return code in CODES_UE_VALIDES
DEVENIR_EXPL = {
diff --git a/app/scodoc/sco_cursus_dut.py b/app/scodoc/sco_cursus_dut.py
index 3474081ff..0549f6677 100644
--- a/app/scodoc/sco_cursus_dut.py
+++ b/app/scodoc/sco_cursus_dut.py
@@ -890,7 +890,7 @@ def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite)
car ils ne dépendent que de la note d'UE et de la validation ou non du semestre.
Les UE des semestres NON ASSIDUS ne sont jamais validées (code AJ).
"""
- valid_semestre = CODES_SEM_VALIDES.get(code_etat_sem, False)
+ valid_semestre = code_etat_sem in CODES_SEM_VALIDES
cnx = ndb.GetDBConnexion()
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py
index 1bea3b71f..b904bd5b5 100644
--- a/app/scodoc/sco_edit_apc.py
+++ b/app/scodoc/sco_edit_apc.py
@@ -99,7 +99,7 @@ def html_edit_formation_apc(
H = [
render_template(
- "pn/form_ues.html",
+ "pn/form_ues.j2",
formation=formation,
semestre_ids=semestre_ids,
editable=editable,
@@ -122,7 +122,7 @@ def html_edit_formation_apc(
).first()
H += [
render_template(
- "pn/form_mods.html",
+ "pn/form_mods.j2",
formation=formation,
titre=f"Ressources du S{semestre_idx}",
create_element_msg="créer une nouvelle ressource",
@@ -138,7 +138,7 @@ def html_edit_formation_apc(
if ues_by_sem[semestre_idx].count() > 0
else "",
render_template(
- "pn/form_mods.html",
+ "pn/form_mods.j2",
formation=formation,
titre=f"Situations d'Apprentissage et d'Évaluation (SAÉs) S{semestre_idx}",
create_element_msg="créer une nouvelle SAÉ",
@@ -154,7 +154,7 @@ def html_edit_formation_apc(
if ues_by_sem[semestre_idx].count() > 0
else "",
render_template(
- "pn/form_mods.html",
+ "pn/form_mods.j2",
formation=formation,
titre=f"Autres modules (non BUT) du S{semestre_idx}",
create_element_msg="créer un nouveau module",
@@ -196,7 +196,7 @@ def html_ue_infos(ue):
and ue.matieres.count() == 0
)
return render_template(
- "pn/ue_infos.html",
+ "pn/ue_infos.j2",
titre=f"UE {ue.acronyme} {ue.titre}",
ue=ue,
formsemestres=formsemestres,
diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index 5d269875e..906493a2a 100644
--- a/app/scodoc/sco_edit_ue.py
+++ b/app/scodoc/sco_edit_ue.py
@@ -723,7 +723,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
"libjs/jQuery-tagEditor/jquery.caret.min.js",
"js/module_tag_editor.js",
],
- page_title=f"Programme {formation.acronyme}",
+ page_title=f"Programme {formation.acronyme} v{formation.version}",
),
f"""
{formation.to_html()} {lockicon}
@@ -765,7 +765,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
# Description de la formation
H.append(
render_template(
- "pn/form_descr.html",
+ "pn/form_descr.j2",
formation=formation,
parcours=parcours,
editable=editable,
@@ -913,8 +913,12 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
Export XML de la formation
- (permet de la sauvegarder pour l'échanger avec un autre site)
+ }">Export XML de la formation ou
+ sans codes Apogée
+ (permet de l'enregistrer pour l'échanger avec un autre site)