"""
+ annee = (ue.semestre_idx + 1) // 2 # 1, 2, 3
+ niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
+
+ # Les niveaux déjà associés à d'autres UE du même semestre
+ autres_ues = formation.ues.filter_by(semestre_idx=ue.semestre_idx)
+ niveaux_autres_ues = {
+ oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id
+ }
+ options = []
+ if niveaux_by_parcours["TC"]: # TC pour Tronc Commun
+ options.append("""""")
+ for parcour in ref_comp.parcours:
+ if len(niveaux_by_parcours[parcour.id]):
+ options.append(f"""""")
+ options_str = "\n".join(options)
+ return f"""
+
+
+
+ """
+
+
+def set_ue_niveau_competence(ue_id: int, niveau_id: int):
+ """Associe le niveau et l'UE"""
+ log(f"set_ue_niveau_competence( {ue_id}, {niveau_id} )")
+ ue = UniteEns.query.get_or_404(ue_id)
+
+ autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
+ niveaux_autres_ues = {
+ oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id
+ }
+ if niveau_id in niveaux_autres_ues:
+ log(
+ f"set_ue_niveau_competence: denying association of {ue} to already associated {niveau_id}"
+ )
+ return "", 409 # conflict
+ if niveau_id == "":
+ # suppression de l'association
+ ue.niveau_competence = None
+ else:
+ niveau = ApcNiveau.query.get_or_404(niveau_id)
+ ue.niveau_competence = niveau
+ db.session.add(ue)
+ db.session.commit()
+ return "", 204
diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py
index f9dbb8706..3b90187b9 100644
--- a/app/but/bulletin_but.py
+++ b/app/but/bulletin_but.py
@@ -14,6 +14,7 @@ from flask import url_for, g
from app.comp.res_but import ResultatsSemestreBUT
from app.models import FormSemestre, Identite
+from app.models import but_validations
from app.models.groups import GroupDescr
from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu
@@ -244,7 +245,7 @@ class BulletinBUT:
f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}"
for ue in res.ues
if ue.type != UE_SPORT
- and res.modimpls_in_ue(ue.id, etudid)
+ and res.modimpls_in_ue(ue, etudid)
and ue.id in res.bonus_ues
and bonus_vect[ue.id] > 0.0
]
@@ -274,6 +275,11 @@ class BulletinBUT:
etat_inscription = etud.inscription_etat(formsemestre.id)
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
published = (not formsemestre.bul_hide_xml) or force_publishing
+ if formsemestre.formation.referentiel_competence is None:
+ etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
+ else:
+ etud_ues_ids = res.etud_ues_ids(etud.id)
+
d = {
"version": "0",
"type": "BUT",
@@ -318,9 +324,13 @@ class BulletinBUT:
ects_tot = sum([ue.ects or 0 for ue in res.ues]) if res.ues else 0.0
ects_acquis = sum([d.get("ects", 0) for d in decisions_ues.values()])
semestre_infos["ECTS"] = {"acquis": ects_acquis, "total": ects_tot}
- semestre_infos.update(
- sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id)
- )
+ if sco_preferences.get_preference("bul_show_decision", formsemestre.id):
+ semestre_infos.update(
+ sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id)
+ )
+ semestre_infos.update(
+ but_validations.dict_decision_jury(etud, formsemestre)
+ )
if etat_inscription == scu.INSCRIT:
# moyenne des moyennes générales du semestre
semestre_infos["notes"] = {
@@ -365,10 +375,7 @@ class BulletinBUT:
)
for ue in res.ues
# si l'UE comporte des modules auxquels on est inscrit:
- if (
- (ue.type == UE_SPORT)
- or self.res.modimpls_in_ue(ue.id, etud.id)
- )
+ if ((ue.type == UE_SPORT) or ue.id in etud_ues_ids)
},
"semestre": semestre_infos,
},
diff --git a/app/but/forms/jury_but_forms.py b/app/but/forms/jury_but_forms.py
new file mode 100644
index 000000000..0f3719600
--- /dev/null
+++ b/app/but/forms/jury_but_forms.py
@@ -0,0 +1,18 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""ScoDoc 9.3 : Formulaires / jurys BUT
+"""
+
+
+from flask_wtf import FlaskForm
+from wtforms import SubmitField
+
+
+class FormSemestreValidationAutoBUTForm(FlaskForm):
+ "simple form de confirmation"
+ submit = SubmitField("Lancer le calcul")
+ cancel = SubmitField("Annuler")
diff --git a/app/but/forms/refcomp_forms.py b/app/but/forms/refcomp_forms.py
index ce16292df..ee67cba28 100644
--- a/app/but/forms/refcomp_forms.py
+++ b/app/but/forms/refcomp_forms.py
@@ -13,7 +13,9 @@ from wtforms import SelectField, SubmitField
class FormationRefCompForm(FlaskForm):
- referentiel_competence = SelectField("Référentiels déjà chargés")
+ referentiel_competence = SelectField(
+ "Choisir parmi les référentiels déjà chargés :"
+ )
submit = SubmitField("Valider")
cancel = SubmitField("Annuler")
@@ -23,7 +25,7 @@ class RefCompLoadForm(FlaskForm):
"Choisir un référentiel de compétences officiel BUT"
)
upload = FileField(
- label="Ou bien sélectionner un fichier XML au format Orébut",
+ label="... ou bien sélectionner un fichier XML au format Orébut (réservé aux développeurs !)",
validators=[
FileAllowed(
[
diff --git a/app/but/import_refcomp.py b/app/but/import_refcomp.py
index 0f97cd958..9acd1c9db 100644
--- a/app/but/import_refcomp.py
+++ b/app/but/import_refcomp.py
@@ -4,7 +4,6 @@
# See LICENSE
##############################################################################
from xml.etree import ElementTree
-from typing import TextIO
import sqlalchemy
@@ -57,13 +56,13 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
try:
c = ApcCompetence(**ApcCompetence.attr_from_xml(competence.attrib))
db.session.flush()
- except sqlalchemy.exc.IntegrityError:
+ except sqlalchemy.exc.IntegrityError as exc:
# ne devrait plus se produire car pas d'unicité de l'id: donc inutile
db.session.rollback()
raise ScoValueError(
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]})
"""
- )
+ ) from exc
ref.competences.append(c)
# --- SITUATIONS
situations = competence.find("situations")
diff --git a/app/but/jury_but.py b/app/but/jury_but.py
new file mode 100644
index 000000000..0a7660345
--- /dev/null
+++ b/app/but/jury_but.py
@@ -0,0 +1,1008 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Jury BUT: logique de gestion
+
+Utilisation:
+ 1) chargement page jury, pour un étudiant et un formsemestre BUT quelconque
+ - DecisionsProposeesAnnee(formsemestre)
+ cherche l'autre formsemestre de la même année scolaire (peut ne pas exister)
+ cherche les RCUEs de l'année (BUT1, 2, 3)
+ pour un redoublant, le RCUE peut considérer un formsemestre d'une année antérieure.
+
+ on instancie des DecisionsProposees pour les
+ différents éléments (UEs, RCUEs, Année, Diplôme)
+ Cela donne
+ - les codes possibles (dans .codes)
+ - le code actuel si une décision existe déjà (dans code_valide)
+ - pour les UEs, le rcue s'il y en a un)
+
+ 2) Validation pour l'utilisateur (form)) => enregistrement code
+ - on vérifie que le code soumis est bien dans les codes possibles
+ - on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
+ ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
+ - Si RCUE validé, on déclenche d'éventuelles validations:
+ ("La validation des deux UE du niveau d’une compétence emporte la validation
+ de l’ensemble des UE du niveau inférieur de cette même compétence.")
+
+Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une
+autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`.
+Les jurys de semestres pairs non (S2, S4, S6): il y a une décision sur l'année (ETP)
+ - autorisation en S_2n+1 (S3 ou S5) si: ADM, ADJ, PASD, PAS1CN
+ - autorisation en S2n-1 (S1, S3 ou S5) si: RED
+ - rien si pour les autres codes d'année.
+
+Le formulaire permet de choisir des codes d'UE, RCUE et Année (ETP).
+Mais normalement, les codes d'UE sont à choisir: les RCUE et l'année s'en déduisent.
+Si l'utilisateur coche "décision manuelle", il peut alors choisir les codes RCUE et années.
+
+La soumission du formulaire:
+ - etud, formation
+ - UEs: [(formsemestre, ue, code), ...]
+ - RCUE: [(formsemestre, ue, code), ...] le formsemestre est celui d'indice pair du niveau
+ (S2, S4 ou S6), il sera regoupé avec celui impair de la même année ou de la suivante.
+ - Année: [(formsemestre, code)]
+
+DecisionsProposeesAnnee:
+ si 1/2 des rcue et aucun < 8 + pour S5 condition sur les UE de BUT1 et BUT2
+ => charger les DecisionsProposeesRCUE
+
+DecisionsProposeesRCUE: les RCUEs pour cette année
+ validable, compensable, ajourné. Utilise classe RegroupementCoherentUE
+
+DecisionsProposeesUE: décisions de jury sur une UE du BUT
+ initialisation sans compensation (ue isolée), mais
+ DecisionsProposeesRCUE appelera .set_compensable()
+ si on a la possibilité de la compenser dans le RCUE.
+"""
+import html
+from operator import attrgetter
+import re
+from typing import Union
+
+from flask import g, url_for
+
+from app import db
+from app import log
+from app.comp.res_but import ResultatsSemestreBUT
+from app.comp import res_sem
+from app.models import formsemestre
+
+from app.models.but_refcomp import (
+ ApcAnneeParcours,
+ ApcCompetence,
+ ApcNiveau,
+ ApcParcours,
+ ApcParcoursNiveauCompetence,
+)
+from app.models import Scolog, ScolarAutorisationInscription
+from app.models.but_validations import (
+ ApcValidationAnnee,
+ ApcValidationRCUE,
+ RegroupementCoherentUE,
+)
+from app.models.etudiants import Identite
+from app.models.formations import Formation
+from app.models.formsemestre import FormSemestre, FormSemestreInscription
+from app.models.ues import UniteEns
+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
+
+
+class NoRCUEError(ScoValueError):
+ """Erreur en cas de RCUE manquant"""
+
+ def __init__(self, deca: "DecisionsProposeesAnnee", ue: UniteEns):
+ if all(u.niveau_competence for u in deca.ues_pair):
+ warning_pair = ""
+ else:
+ warning_pair = """
certaines UE du semestre pair ne sont pas associées à un niveau de compétence
"""
+ if all(u.niveau_competence for u in deca.ues_impair):
+ warning_impair = ""
+ else:
+ warning_impair = """
certaines UE du semestre impair ne sont pas associées à un niveau de compétence
"""
+ msg = (
+ f"""
Pas de RCUE pour l'UE {ue.acronyme}
+ {warning_impair}
+ {warning_pair}
+
UE {ue.acronyme}: niveau {html.escape(str(ue.niveau_competence))}
+
UEs impaires: {html.escape(', '.join(str(u.niveau_competence or "pas de niveau")
+ for u in deca.ues_impair))}
+
+ """
+ + deca.infos()
+ )
+ super().__init__(msg)
+
+
+class DecisionsProposees:
+ """Une décision de jury proposé, constituée d'une liste de codes et d'une explication.
+ Super-classe, spécialisée pour les UE, les RCUE, les années et le diplôme.
+
+ validation : None ou une instance de d'une classe avec un champ code
+ ApcValidationRCUE, ApcValidationAnnee ou ScolarFormSemestreValidation
+ """
+
+ # Codes toujours proposés sauf si include_communs est faux:
+ codes_communs = [
+ sco_codes.RAT,
+ sco_codes.DEF,
+ sco_codes.ABAN,
+ sco_codes.DEM,
+ sco_codes.UEBSL,
+ ]
+
+ def __init__(
+ self,
+ etud: Identite = None,
+ code: Union[str, list[str]] = None,
+ explanation="",
+ code_valide=None,
+ include_communs=True,
+ ):
+ self.etud = etud
+ self.codes = []
+ "Les codes attribuables par ce jury"
+ if include_communs:
+ self.codes = self.codes_communs.copy()
+ if isinstance(code, list):
+ self.codes = code + self.codes
+ elif code is not None:
+ self.codes = [code] + self.codes
+ self.validation = None
+ "Validation enregistrée"
+ self.code_valide: str = code_valide
+ "Code décision actuel enregistré"
+ self.explanation: str = explanation
+ "Explication à afficher à côté de la décision"
+ self.recorded = False
+ "true si la décision vient d'être enregistrée"
+
+ def __repr__(self) -> str:
+ return f"""<{self.__class__.__name__} valid={self.code_valide
+ } codes={self.codes} explanation={self.explanation}"""
+
+
+class DecisionsProposeesAnnee(DecisionsProposees):
+ """Décisions de jury sur une année (ETP) du BUT
+
+ Le texte:
+ La poursuite d'études dans un semestre pair d’une même année est de droit
+ pour tout étudiant. La poursuite d’études dans un semestre impair est
+ possible si et seulement si l’étudiant a obtenu :
+ - la moyenne à plus de la moitié des regroupements cohérents d’UE;
+ - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
+ La poursuite d'études dans le semestre 5 nécessite de plus la validation
+ de toutes les UE des semestres 1 et 2 dans les conditions de validation
+ des points 4.3 (moy_ue >= 10) et 4.4 (compensation rcue), ou par décision
+ de jury.
+ """
+
+ # Codes toujours proposés sauf si include_communs est faux:
+ codes_communs = [
+ sco_codes.RAT,
+ sco_codes.ABAN,
+ sco_codes.ABL,
+ sco_codes.ATJ,
+ sco_codes.DEF,
+ sco_codes.DEM,
+ sco_codes.EXCLU,
+ ]
+
+ def __init__(
+ self,
+ etud: Identite,
+ formsemestre: FormSemestre,
+ ):
+ super().__init__(etud=etud)
+ self.formsemestre_id = formsemestre.id
+ formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre)
+ assert (
+ (formsemestre_pair is None)
+ or (formsemestre_impair is None)
+ or (
+ ((formsemestre_pair.semestre_id - formsemestre_impair.semestre_id) == 1)
+ and (
+ formsemestre_pair.formation.referentiel_competence_id
+ == formsemestre_impair.formation.referentiel_competence_id
+ )
+ )
+ )
+
+ self.formsemestre_impair = formsemestre_impair
+ "le 1er semestre de l'année scolaire considérée (S1, S3, S5)"
+ self.formsemestre_pair = formsemestre_pair
+ "le second formsemestre de la même année scolaire (S2, S4, S6)"
+ self.annee_but = (
+ (formsemestre_impair.semestre_id + 1) // 2
+ if formsemestre_impair
+ else (formsemestre_pair.semestre_id + 1) // 2
+ )
+ "le rang de l'année dans le BUT: 1, 2, 3"
+ assert self.annee_but in (1, 2, 3)
+ self.rcues_annee = []
+ "RCUEs de l'année"
+ if self.formsemestre_impair is not None:
+ self.validation = ApcValidationAnnee.query.filter_by(
+ etudid=self.etud.id,
+ formsemestre_id=formsemestre_impair.id,
+ ordre=self.annee_but,
+ ).first()
+ else:
+ self.validation = None
+ if self.validation is not None:
+ self.code_valide = self.validation.code
+ self.parcour = None
+ "Le parcours considéré (celui du semestre pair, ou à défaut impair)"
+ if self.formsemestre_pair is not None:
+ self.res_pair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
+ self.formsemestre_pair
+ )
+ else:
+ self.res_pair = None
+ if self.formsemestre_impair is not None:
+ self.res_impair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
+ self.formsemestre_impair
+ )
+ else:
+ self.res_impair = None
+
+ self.ues_impair, self.ues_pair = self.compute_ues_annee() # pylint: disable=all
+ self.decisions_ues = {
+ ue.id: DecisionsProposeesUE(etud, formsemestre_impair, ue)
+ for ue in self.ues_impair
+ }
+ "{ue_id : DecisionsProposeesUE} pour toutes les UE de l'année"
+ self.decisions_ues.update(
+ {
+ ue.id: DecisionsProposeesUE(etud, formsemestre_pair, ue)
+ for ue in self.ues_pair
+ }
+ )
+ self.rcues_annee = self.compute_rcues_annee()
+
+ formation = (
+ self.formsemestre_impair.formation
+ if self.formsemestre_impair
+ else self.formsemestre_pair.formation
+ )
+ self.niveaux_competences = ApcNiveau.niveaux_annee_de_parcours(
+ self.parcour, self.annee_but, formation.referentiel_competence
+ ).all() # non triés
+ "liste des niveaux de compétences associés à cette année"
+ self.decisions_rcue_by_niveau = self.compute_decisions_niveaux()
+ "les décisions rcue associées aux niveau_id"
+ self.dec_rcue_by_ue = self._dec_rcue_by_ue()
+ "{ ue_id : DecisionsProposeesRCUE } pour toutes les UE associées à un niveau"
+ self.nb_competences = len(self.niveaux_competences)
+ "le nombre de niveaux de compétences à valider cette année"
+ rcues_avec_niveau = [d.rcue for d in self.decisions_rcue_by_niveau.values()]
+ self.nb_validables = len(
+ [rcue for rcue in rcues_avec_niveau if rcue.est_validable()]
+ )
+ "le nombre de comp. validables (éventuellement par compensation)"
+ self.nb_rcues_under_8 = len(
+ [rcue for rcue in rcues_avec_niveau if not rcue.est_suffisant()]
+ )
+ "le nb de comp. sous la barre de 8/20"
+ # année ADM si toutes RCUE validées (sinon PASD)
+ self.admis = self.nb_validables == self.nb_competences
+ "vrai si l'année est réussie, tous niveaux validables"
+ self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2)
+ # Peut passer si plus de la moitié validables et tous > 8
+ self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
+ # XXX TODO ajouter condition pour passage en S5
+
+ # Enfin calcule les codes des UE:
+ for dec_ue in self.decisions_ues.values():
+ dec_ue.compute_codes()
+
+ # Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR
+ expl_rcues = (
+ f"{self.nb_validables} niveau validable(s) sur {self.nb_competences}"
+ )
+ if self.admis:
+ self.codes = [sco_codes.ADM] + self.codes
+ self.explanation = expl_rcues
+ elif self.passage_de_droit:
+ self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes
+ self.explanation = expl_rcues
+ elif self.valide_moitie_rcue: # mais au moins 1 rcue insuffisante
+ self.codes = [
+ sco_codes.RED,
+ sco_codes.NAR,
+ sco_codes.PAS1NCI,
+ sco_codes.ADJ,
+ ] + self.codes
+ self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} < 8"
+ else:
+ self.codes = [
+ sco_codes.RED,
+ sco_codes.NAR,
+ sco_codes.PAS1NCI,
+ sco_codes.ADJ,
+ ] + self.codes
+ self.explanation = (
+ expl_rcues
+ + f""" et {self.nb_rcues_under_8}
+ niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
+ )
+ #
+
+ def infos(self) -> str:
+ "informations, for debugging purpose"
+ return f"""DecisionsProposeesAnnee
+
+ """
+
+ def annee_scolaire(self) -> int:
+ "L'année de début de l'année scolaire"
+ formsemestre = self.formsemestre_impair or self.formsemestre_pair
+ return formsemestre.annee_scolaire()
+
+ def annee_scolaire_str(self) -> str:
+ "L'année scolaire, eg '2021 - 2022'"
+ formsemestre = self.formsemestre_impair or self.formsemestre_pair
+ return formsemestre.annee_scolaire_str().replace(" ", "")
+
+ def comp_formsemestres(
+ self, formsemestre: FormSemestre
+ ) -> tuple[FormSemestre, FormSemestre]:
+ """les deux formsemestres de l'année scolaire à laquelle appartient formsemestre."""
+ if not formsemestre.formation.is_apc(): # garde fou
+ return None, None
+ if formsemestre.semestre_id % 2 == 0:
+ other_semestre_id = formsemestre.semestre_id - 1
+ else:
+ other_semestre_id = formsemestre.semestre_id + 1
+ annee_scolaire = formsemestre.annee_scolaire()
+ other_formsemestre = None
+ for inscr in self.etud.formsemestre_inscriptions:
+ if (
+ # Même spécialité BUT (tolère ainsi des variantes de formation)
+ (
+ inscr.formsemestre.formation.referentiel_competence
+ == formsemestre.formation.referentiel_competence
+ )
+ # L'autre semestre
+ and (inscr.formsemestre.semestre_id == other_semestre_id)
+ # de la même année scolaire:
+ and (inscr.formsemestre.annee_scolaire() == annee_scolaire)
+ ):
+ other_formsemestre = inscr.formsemestre
+ if formsemestre.semestre_id % 2 == 0:
+ return other_formsemestre, formsemestre
+ return formsemestre, other_formsemestre
+
+ def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]:
+ """UEs à valider cette année pour cet étudiant, selon son parcours.
+ Ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
+ """
+ etudid = self.etud.id
+ ues_sems = []
+ for (formsemestre, res) in (
+ (self.formsemestre_impair, self.res_impair),
+ (self.formsemestre_pair, self.res_pair),
+ ):
+ if (formsemestre is None) or (not formsemestre.formation.is_apc()):
+ ues = []
+ else:
+ formation: Formation = formsemestre.formation
+ # Parcour dans lequel l'étudiant est inscrit, et liste des UEs
+ if res.etuds_parcour_id[etudid] is None:
+ # pas de parcour: prend toutes les UEs (non bonus)
+ ues = [ue for ue in res.etud_ues(etudid) if ue.type == UE_STANDARD]
+ ues.sort(key=lambda u: u.numero)
+ else:
+ parcour = ApcParcours.query.get(res.etuds_parcour_id[etudid])
+ if parcour is not None:
+ self.parcour = parcour
+ ues = (
+ formation.query_ues_parcour(parcour)
+ .filter_by(semestre_idx=formsemestre.semestre_id)
+ .order_by(UniteEns.numero)
+ .all()
+ )
+ ues_sems.append(ues)
+ return ues_sems
+
+ def check_ues_ready_jury(self) -> list[str]:
+ """Vérifie que les toutes les UEs (hors bonus) de l'année sont
+ bien associées à des niveaux de compétences.
+ Renvoie liste vide si ok, sinon liste de message explicatifs
+ """
+ messages = []
+ for ue in self.ues_impair + self.ues_pair:
+ if ue.niveau_competence is None:
+ messages.append(
+ f"UE {ue.acronyme} non associée à un niveau de compétence"
+ )
+ if ue.semestre_idx is None:
+ messages.append(
+ f"UE {ue.acronyme} n'a pas d'indice de semestre dans la formation"
+ )
+ return messages
+
+ def compute_rcues_annee(self) -> list[RegroupementCoherentUE]:
+ """Liste des regroupements d'UE à considérer cette année.
+ Pour le moment on ne considère pas de RCUE à cheval sur plusieurs années (redoublants).
+ Si on n'a pas les deux semestres, aucun RCUE.
+ Raises ScoValueError s'il y a des UE sans RCUE.
+ """
+ if self.formsemestre_pair is None or self.formsemestre_impair is None:
+ return []
+ rcues_annee = []
+ ues_impair_sans_rcue = {ue.id for ue in self.ues_impair}
+ for ue_pair in self.ues_pair:
+ rcue = None
+ for ue_impair in self.ues_impair:
+ if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
+ rcue = RegroupementCoherentUE(
+ self.etud,
+ self.formsemestre_impair,
+ ue_impair,
+ self.formsemestre_pair,
+ ue_pair,
+ )
+ ues_impair_sans_rcue.discard(ue_impair.id)
+ break
+ if rcue is None:
+ raise NoRCUEError(deca=self, ue=ue_pair)
+ rcues_annee.append(rcue)
+ if len(ues_impair_sans_rcue) > 0:
+ ue = UniteEns.query.get(ues_impair_sans_rcue.pop())
+ raise NoRCUEError(deca=self, ue=ue)
+ return rcues_annee
+
+ def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
+ """Pour chaque niveau de compétence de cette année, construit
+ le DecisionsProposeesRCUE,
+ ou None s'il n'y en a pas
+ (ne devrait pas arriver car compute_rcues_annee vérifie déjà cela).
+ Return: { niveau_id : DecisionsProposeesRCUE }
+ """
+ # Retrouve le RCUE associé à chaque niveau
+ rc_niveaux = []
+ for niveau in self.niveaux_competences:
+ rcue = None
+ for rc in self.rcues_annee:
+ if rc.ue_1.niveau_competence_id == niveau.id:
+ rcue = rc
+ break
+ if rcue is not None:
+ dec_rcue = DecisionsProposeesRCUE(self, rcue)
+ rc_niveaux.append((dec_rcue, niveau.id))
+ # prévient les UE concernées :-)
+ self.decisions_ues[dec_rcue.rcue.ue_1.id].set_rcue(dec_rcue.rcue)
+ self.decisions_ues[dec_rcue.rcue.ue_2.id].set_rcue(dec_rcue.rcue)
+ # Ordonne par numéro d'UE
+ rc_niveaux.sort(key=lambda x: x[0].rcue.ue_1.numero)
+ decisions_rcue_by_niveau = {x[1]: x[0] for x in rc_niveaux}
+ return decisions_rcue_by_niveau
+
+ def _dec_rcue_by_ue(self) -> dict[int, "DecisionsProposeesRCUE"]:
+ """construit dict { ue_id : DecisionsProposeesRCUE }
+ à partir de self.decisions_rcue_by_niveau"""
+ d = {}
+ for dec_rcue in self.decisions_rcue_by_niveau.values():
+ d[dec_rcue.rcue.ue_1.id] = dec_rcue
+ d[dec_rcue.rcue.ue_2.id] = dec_rcue
+ return d
+
+ def next_annee_semestre_id(self, code: str) -> int:
+ """L'indice du semestre dans lequel l'étudiant est autorisé à
+ poursuivre l'année suivante. None si aucun."""
+ if self.formsemestre_pair is None:
+ return None # seulement sur année
+ if code == RED:
+ return self.formsemestre_pair.semestre_id - 1
+ elif (
+ code in sco_codes.BUT_CODES_PASSAGE
+ and self.formsemestre_pair.semestre_id < sco_codes.ParcoursBUT.NB_SEM
+ ):
+ return self.formsemestre_pair.semestre_id + 1
+ return None
+
+ def record_form(self, form: dict):
+ """Enregistre les codes de jury en base
+ form dict:
+ - 'code_ue_1896' : 'AJ' code pour l'UE id 1896
+ - 'code_rcue_6" : 'ADM' code pour le RCUE du niveau 6
+ - 'code_annee' : 'ADM' code pour l'année
+
+ Si les code_rcue et le code_annee ne sont pas fournis,
+ et qu'il n'y en a pas déjà, enregistre ceux par défaut.
+ """
+ for key in form:
+ code = form[key]
+ # Codes d'UE
+ m = re.match(r"^code_ue_(\d+)$", key)
+ if m:
+ ue_id = int(m.group(1))
+ dec_ue = self.decisions_ues.get(ue_id)
+ if not dec_ue:
+ raise ScoValueError(f"UE invalide ue_id={ue_id}")
+ dec_ue.record(code)
+ else:
+ # Codes de RCUE
+ m = re.match(r"^code_rcue_(\d+)$", key)
+ if m:
+ niveau_id = int(m.group(1))
+ dec_rcue = self.decisions_rcue_by_niveau.get(niveau_id)
+ if not dec_rcue:
+ raise ScoValueError(f"RCUE invalide niveau_id={niveau_id}")
+ dec_rcue.record(code)
+ elif key == "code_annee":
+ # Code annuel
+ self.record(code)
+
+ self.record_all()
+ db.session.commit()
+
+ def record(self, code: str, no_overwrite=False):
+ """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é.
+ """
+ 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
+ if self.validation:
+ db.session.delete(self.validation)
+ db.session.flush()
+ if code is None:
+ self.validation = None
+ else:
+ self.validation = ApcValidationAnnee(
+ etudid=self.etud.id,
+ formsemestre=self.formsemestre_impair,
+ ordre=self.annee_but,
+ annee_scolaire=self.annee_scolaire(),
+ code=code,
+ )
+ Scolog.logdb(
+ method="jury_but",
+ etudid=self.etud.id,
+ msg=f"Validation année BUT{self.annee_but}: {code}",
+ )
+ db.session.add(self.validation)
+ # --- Autorisation d'inscription dans semestre suivant ?
+ if self.formsemestre_pair is not None:
+ if code is None:
+ ScolarAutorisationInscription.delete_autorisation_etud(
+ etudid=self.etud.id,
+ origin_formsemestre_id=self.formsemestre_pair.id,
+ )
+ else:
+ next_semestre_id = self.next_annee_semestre_id(code)
+ if next_semestre_id is not None:
+ ScolarAutorisationInscription.autorise_etud(
+ self.etud.id,
+ self.formsemestre_pair.formation.formation_code,
+ self.formsemestre_pair.id,
+ next_semestre_id,
+ )
+
+ self.recorded = True
+
+ def record_all(self):
+ """Enregistre les codes qui n'ont pas été spécifiés par le formulaire, et sont donc en mode "automatique" """
+ decisions = (
+ list(self.decisions_ues.values())
+ + list(self.decisions_rcue_by_niveau.values())
+ + [self]
+ )
+ for dec in decisions:
+ if not dec.recorded:
+ # rappel: le code par défaut est en tête
+ code = dec.codes[0] if dec.codes else None
+ # s'il n'y a pas de code, efface
+ dec.record(code, no_overwrite=True)
+
+ def erase(self):
+ """Efface les décisions de jury de cet étudiant
+ pour cette année: décisions d'UE, de RCUE, d'année,
+ et autorisations d'inscription émises.
+ """
+ for dec_ue in self.decisions_ues.values():
+ dec_ue.erase()
+ for dec_rcue in self.decisions_rcue_by_niveau.values():
+ dec_rcue.erase()
+ if self.formsemestre_impair:
+ ScolarAutorisationInscription.delete_autorisation_etud(
+ self.etud.id, self.formsemestre_impair.id
+ )
+ if self.formsemestre_pair:
+ ScolarAutorisationInscription.delete_autorisation_etud(
+ self.etud.id, self.formsemestre_pair.id
+ )
+ validations = ApcValidationAnnee.query.filter_by(
+ etudid=self.etud.id,
+ formsemestre_id=self.formsemestre_impair.id,
+ ordre=self.annee_but,
+ )
+ for validation in validations:
+ db.session.delete(validation)
+ db.session.flush()
+
+ def get_autorisations_passage(self) -> list[int]:
+ """Les liste des indices de semestres auxquels on est autorisé à
+ s'inscrire depuis cette année"""
+ formsemestre = self.formsemestre_pair or self.formsemestre_impair
+ if not formsemestre:
+ return []
+ return [
+ a.semestre_id
+ for a in ScolarAutorisationInscription.query.filter_by(
+ etudid=self.etud.id,
+ origin_formsemestre_id=formsemestre.id,
+ )
+ ]
+
+ def descr_niveaux_validation(self, line_sep: str = "\n") -> str:
+ """Description textuelle des niveaux validés (enregistrés)
+ pour PV jurys
+ """
+ validations = [
+ dec_rcue.descr_validation()
+ for dec_rcue in self.decisions_rcue_by_niveau.values()
+ ]
+ return line_sep.join(v for v in validations if v)
+
+ def descr_ues_validation(self, line_sep: str = "\n") -> str:
+ """Description textuelle des UE validées (enregistrés)
+ pour PV jurys
+ """
+ validations = []
+ for res in (self.res_impair, self.res_pair):
+ if res:
+ dec_ues = [
+ self.decisions_ues[ue.id]
+ for ue in res.ues
+ if ue.type == UE_STANDARD and ue.id in self.decisions_ues
+ ]
+ valids = [dec_ue.descr_validation() for dec_ue in dec_ues]
+ validations.append(", ".join(v for v in valids if v))
+ return line_sep.join(validations)
+
+
+class DecisionsProposeesRCUE(DecisionsProposees):
+ """Liste des codes de décisions que l'on peut proposer pour
+ le RCUE de cet étudiant dans cette année.
+
+ ADM, CMP, ADJ, AJ, RAT, DEF, ABAN
+ """
+
+ codes_communs = [
+ sco_codes.ADJ,
+ sco_codes.ATJ,
+ sco_codes.RAT,
+ sco_codes.DEF,
+ sco_codes.ABAN,
+ ]
+
+ def __init__(
+ self, dec_prop_annee: DecisionsProposeesAnnee, rcue: RegroupementCoherentUE
+ ):
+ super().__init__(etud=dec_prop_annee.etud)
+ self.rcue = rcue
+ if rcue is None: # RCUE non dispo, eg un seul semestre
+ self.codes = []
+ return
+ self.parcour = dec_prop_annee.parcour
+ self.validation = rcue.query_validations().first()
+ if self.validation is not None:
+ self.code_valide = self.validation.code
+ if rcue.est_compensable():
+ self.codes.insert(0, sco_codes.CMP)
+ elif rcue.est_validable():
+ self.codes.insert(0, sco_codes.ADM)
+ else:
+ self.codes.insert(0, sco_codes.AJ)
+
+ def record(self, code: str, no_overwrite=False):
+ """Enregistre le code"""
+ 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
+ parcours_id = self.parcour.id if self.parcour is not None else None
+ if self.validation:
+ db.session.delete(self.validation)
+ db.session.flush()
+ if code is None:
+ self.validation = None
+ else:
+ self.validation = ApcValidationRCUE(
+ etudid=self.etud.id,
+ formsemestre_id=self.rcue.formsemestre_2.id,
+ ue1_id=self.rcue.ue_1.id,
+ ue2_id=self.rcue.ue_2.id,
+ parcours_id=parcours_id,
+ code=code,
+ )
+ Scolog.logdb(
+ method="jury_but",
+ etudid=self.etud.id,
+ msg=f"Validation RCUE {repr(self.rcue)}",
+ )
+ db.session.add(self.validation)
+ self.recorded = True
+
+ def erase(self):
+ """Efface la décision de jury de cet étudiant pour cet RCUE"""
+ # par prudence, on requete toutes les validations, en cas de doublons
+ validations = self.rcue.query_validations()
+ for validation in validations:
+ db.session.delete(validation)
+ db.session.flush()
+
+ def descr_validation(self) -> str:
+ """Description validation niveau enregistrée, pour PV jury.
+ Si le niveau est validé, done son acronyme, sinon chaine vide.
+ """
+ if self.code_valide in sco_codes.CODES_RCUE_VALIDES:
+ if (
+ self.rcue and self.rcue.ue_1 and self.rcue.ue_1.niveau_competence
+ ): # prudence !
+ niveau_titre = self.rcue.ue_1.niveau_competence.competence.titre or ""
+ ordre = self.rcue.ue_1.niveau_competence.ordre
+ else:
+ return "?" # oups ?
+ return f"{niveau_titre} niv. {ordre}"
+ return ""
+
+
+class DecisionsProposeesUE(DecisionsProposees):
+ """Décisions de jury sur une UE du BUT
+
+ Liste des codes de décisions que l'on peut proposer pour
+ cette UE d'un étudiant dans un semestre.
+
+ Si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL
+
+ si moy_ue > 10, ADM
+ sinon si compensation dans RCUE: CMP
+ sinon: ADJ, AJ
+
+ et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL (codes_communs)
+ """
+
+ # Codes toujours proposés sauf si include_communs est faux:
+ codes_communs = [
+ sco_codes.RAT,
+ sco_codes.DEF,
+ sco_codes.ABAN,
+ sco_codes.ATJ,
+ sco_codes.DEM,
+ sco_codes.UEBSL,
+ ]
+
+ def __init__(
+ self,
+ etud: Identite,
+ formsemestre: FormSemestre,
+ ue: UniteEns,
+ ):
+ super().__init__(etud=etud)
+ self.formsemestre = formsemestre
+ self.ue: UniteEns = ue
+ self.rcue: RegroupementCoherentUE = None
+ "Le rcu auquel est rattaché cette UE, ou None"
+ # Une UE peut être validée plusieurs fois en cas de redoublement (qu'elle soit capitalisée ou non)
+ # mais ici on a restreint au formsemestre donc une seule (prend la première)
+ self.validation = ScolarFormSemestreValidation.query.filter_by(
+ etudid=self.etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
+ ).first()
+ if self.validation is not None:
+ self.code_valide = self.validation.code
+ if ue.type == sco_codes.UE_SPORT:
+ self.explanation = "UE bonus, pas de décision de jury"
+ self.codes = [] # aucun code proposé
+ return
+
+ # Moyenne de l'UE ?
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
+ if not ue.id in res.etud_moy_ue:
+ self.explanation = "UE sans résultat"
+ return
+ if not etud.id in res.etud_moy_ue[ue.id]:
+ self.explanation = "Étudiant sans résultat dans cette UE"
+ return
+ self.moy_ue = res.etud_moy_ue[ue.id][etud.id]
+
+ def set_rcue(self, rcue: RegroupementCoherentUE):
+ """Rattache cette UE à un RCUE. Cela peut modifier les codes
+ proposés (si compensation)"""
+ self.rcue = rcue
+
+ def compute_codes(self):
+ """Calcul des .codes attribuables et de l'explanation associée"""
+ if self.moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE):
+ self.codes.insert(0, sco_codes.ADM)
+ self.explanation = (f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20",)
+ elif self.rcue and self.rcue.est_compensable():
+ self.codes.insert(0, sco_codes.CMP)
+ self.explanation = "compensable dans le RCUE"
+ else:
+ # Échec à valider cette UE
+ self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
+ self.explanation = "notes insuffisantes"
+
+ def record(self, code: str, no_overwrite=False):
+ """Enregistre le code"""
+ 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
+ if self.validation:
+ db.session.delete(self.validation)
+ db.session.flush()
+ if code is None:
+ self.validation = None
+ else:
+ self.validation = ScolarFormSemestreValidation(
+ etudid=self.etud.id,
+ formsemestre_id=self.formsemestre.id,
+ ue_id=self.ue.id,
+ code=code,
+ moy_ue=self.moy_ue,
+ )
+ Scolog.logdb(
+ method="jury_but",
+ etudid=self.etud.id,
+ msg=f"Validation UE {self.ue.id}",
+ )
+ db.session.add(self.validation)
+ self.recorded = True
+
+ def erase(self):
+ """Efface la décision de jury de cet étudiant pour cette UE"""
+ # par prudence, on requete toutes les validations, en cas de doublons
+ validations = ScolarFormSemestreValidation.query.filter_by(
+ etudid=self.etud.id, formsemestre_id=self.formsemestre.id, ue_id=self.ue.id
+ )
+ for validation in validations:
+ db.session.delete(validation)
+ db.session.flush()
+
+ def descr_validation(self) -> str:
+ """Description validation niveau enregistrée, pour PV jury.
+ Si l'UE est validée, donne son acronyme, sinon chaine vide.
+ """
+ if self.code_valide in sco_codes.CODES_UE_VALIDES:
+ return f"{self.ue.acronyme}"
+ return ""
+
+
+class BUTCursusEtud: # WIP TODO
+ """Validation du cursus d'un étudiant"""
+
+ def __init__(self, formsemestre: FormSemestre, etud: Identite):
+ if formsemestre.formation.referentiel_competence is None:
+ raise ScoException("BUTCursusEtud: pas de référentiel de compétences")
+ assert len(etud.formsemestre_inscriptions) > 0
+ self.formsemestre = formsemestre
+ self.etud = etud
+ #
+ # La dernière inscription en date va donner le parcours (donc les compétences à valider)
+ self.last_inscription = sorted(
+ etud.formsemestre_inscriptions, key=attrgetter("formsemestre.date_debut")
+ )[-1]
+
+ def est_diplomable(self) -> bool:
+ """Vrai si toutes les compétences sont validables"""
+ return all(
+ self.competence_validable(competence)
+ for competence in self.competences_du_parcours()
+ )
+
+ def est_diplome(self) -> bool:
+ """Vrai si BUT déjà validé"""
+ # vrai si la troisième année est validée
+ # On cherche les validations de 3ieme annee (ordre=3) avec le même référentiel
+ # de formation que nous.
+ return (
+ ApcValidationAnnee.query.filter_by(etudid=self.etud.id, ordre=3)
+ .join(FormSemestre, FormSemestre.id == ApcValidationAnnee.formsemestre_id)
+ .join(Formation, FormSemestre.formation_id == Formation.id)
+ .filter(
+ Formation.referentiel_competence_id
+ == self.formsemestre.formation.referentiel_competence_id
+ )
+ .count()
+ > 0
+ )
+
+ def competences_du_parcours(self) -> list[ApcCompetence]:
+ """Construit liste des compétences du parcours, qui doivent être
+ validées pour obtenir le diplôme.
+ Le parcours est celui de la dernière inscription.
+ """
+ parcour = self.last_inscription.parcour
+ query = self.formsemestre.formation.formation.query_competences_parcour(parcour)
+ if query is None:
+ return []
+ return query.all()
+
+ def competence_validee(self, competence: ApcCompetence) -> bool:
+ """Vrai si la compétence est validée, c'est à dire que tous ses
+ niveaux sont validés (ApcValidationRCUE).
+ """
+ # XXX A REVOIR
+ validations = (
+ ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
+ .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
+ .join(ApcNiveau, ApcNiveau.id == UniteEns.niveau_competence_id)
+ .join(ApcCompetence, ApcCompetence.id == ApcNiveau.competence_id)
+ )
+
+ def competence_validable(self, competence: ApcCompetence):
+ """Vrai si la compétence est "validable" automatiquement, c'est à dire
+ que les conditions de notes sont satisfaites pour l'acquisition de
+ son niveau le plus élevé, qu'il ne manque que l'enregistrement de la décision.
+
+ En vertu de la règle "La validation des deux UE du niveau d’une compétence
+ emporte la validation de l'ensemble des UE du niveau inférieur de cette
+ même compétence.",
+ il suffit de considérer le dernier niveau dans lequel l'étudiant est inscrit.
+ """
+ pass
+
+ def ues_emportees(self, niveau: ApcNiveau) -> list[tuple[FormSemestre, UniteEns]]:
+ """La liste des UE à valider si on valide ce niveau.
+ Ne liste que les UE qui ne sont pas déjà acquises.
+
+ Selon la règle donnée par l'arrêté BUT:
+ * La validation des deux UE du niveau d’une compétence emporte la validation de
+ l'ensemble des UE du niveau inférieur de cette même compétence.
+ """
+ pass
diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py
new file mode 100644
index 000000000..095c5efc9
--- /dev/null
+++ b/app/but/jury_but_pv.py
@@ -0,0 +1,137 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Jury BUT: table synthèse résultats semestre / PV
+"""
+from flask import g, request, url_for
+
+from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
+
+from app import log
+from app.but import jury_but
+from app.models.etudiants import Identite
+from app.models.formsemestre import FormSemestre
+from app.scodoc.gen_tables import GenTable
+from app.scodoc import sco_excel
+from app.scodoc.sco_exceptions import ScoValueError
+from app.scodoc import sco_preferences
+from app.scodoc import sco_utils as scu
+
+
+def _descr_cursus_but(etud: Identite) -> str:
+ "description de la liste des semestres BUT suivis"
+ # prend simplement tous les semestre de type APC, ce qui sera faux si
+ # l'étudiant change de spécialité au sein du même département
+ # (ce qui ne peut normalement pas se produire)
+ indices = sorted(
+ [
+ ins.formsemestre.semestre_id
+ if ins.formsemestre.semestre_id is not None
+ else -1
+ for ins in etud.formsemestre_inscriptions
+ if ins.formsemestre.formation.is_apc()
+ ]
+ )
+ return ", ".join(f"S{indice}" for indice in indices)
+
+
+def pvjury_table_but(formsemestre_id: int, format="html") -> list[dict]:
+ """Page récapitulant les décisions de jury BUT
+ formsemestre peut être pair ou impair
+ """
+ formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
+ assert formsemestre.formation.is_apc()
+ title = "Procès-verbal de jury BUT annuel"
+
+ if format == "html":
+ line_sep = " "
+ else:
+ line_sep = "\n"
+ # remplace pour le BUT la fonction sco_pvjury.pvjury_table
+ annee_but = (formsemestre.semestre_id + 1) // 2
+ titles = {
+ "nom": "Nom",
+ "cursus": "Cursus",
+ "ues": "UE validées",
+ "niveaux": "Niveaux de compétences validés",
+ "decision_but": f"Décision BUT{annee_but}",
+ "diplome": "Résultat au diplôme",
+ "devenir": "Devenir",
+ "observations": "Observations",
+ }
+ rows = []
+ for etudid in formsemestre.etuds_inscriptions:
+ etud: Identite = Identite.query.get(etudid)
+ try:
+ deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
+ if deca.annee_but != annee_but: # wtf ?
+ log(
+ f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}"
+ )
+ continue
+ except ScoValueError:
+ deca = None
+ row = {
+ "nom": etud.etat_civil_pv(line_sep=line_sep),
+ "_nom_order": etud.sort_key,
+ "_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
+ "_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
+ "_nom_target": url_for(
+ "scolar.ficheEtud",
+ scodoc_dept=g.scodoc_dept,
+ etudid=etud.id,
+ ),
+ "cursus": _descr_cursus_but(etud),
+ "ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
+ "niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
+ if deca
+ else "-",
+ "decision_but": deca.code_valide if deca else "",
+ "devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()]),
+ }
+
+ rows.append(row)
+
+ rows.sort(key=lambda x: x["_nom_order"])
+
+ # Style excel... passages à la ligne sur \n
+ xls_style_base = sco_excel.excel_make_style()
+ xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top")
+
+ tab = GenTable(
+ base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
+ caption=title,
+ columns_ids=titles.keys(),
+ html_caption=title,
+ html_class="pvjury_table_but table_leftalign",
+ html_title=f"""
+
+ """,
+ html_with_td_classes=True,
+ origin=f"Généré par {scu.sco_version.SCONAME} le {scu.timedate_human_repr()}",
+ page_title=title,
+ pdf_title=title,
+ preferences=sco_preferences.SemPreferences(),
+ rows=rows,
+ table_id="formation_table_recap",
+ titles=titles,
+ xls_columns_width={
+ "nom": 32,
+ "cursus": 12,
+ "ues": 32,
+ "niveaux": 32,
+ "decision_but": 14,
+ "diplome": 17,
+ "devenir": 8,
+ "observations": 12,
+ },
+ xls_style_base=xls_style_base,
+ )
+ return tab.make_page(format=format, javascripts=["js/etud_info.js"], init_qtip=True)
diff --git a/app/but/jury_but_recap.py b/app/but/jury_but_recap.py
new file mode 100644
index 000000000..c2944d98c
--- /dev/null
+++ b/app/but/jury_but_recap.py
@@ -0,0 +1,422 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Jury BUT: table recap annuelle et liens saisie
+"""
+
+import time
+import numpy as np
+from flask import g, url_for
+
+from app.but import jury_but
+from app.but.jury_but import (
+ DecisionsProposeesAnnee,
+ DecisionsProposeesRCUE,
+ DecisionsProposeesUE,
+)
+from app.comp.res_but import ResultatsSemestreBUT
+from app.comp import res_sem
+from app.models.etudiants import Identite
+from app.models.formsemestre import FormSemestre
+
+from app.scodoc.sco_codes_parcours import (
+ BUT_BARRE_RCUE,
+ BUT_BARRE_UE,
+ BUT_BARRE_UE8,
+ BUT_RCUE_SUFFISANT,
+)
+from app.scodoc import sco_formsemestre_status
+from app.scodoc import html_sco_header
+from app.scodoc import sco_utils as scu
+from app.scodoc.sco_exceptions import ScoValueError
+
+
+def formsemestre_saisie_jury_but(
+ formsemestre2: FormSemestre,
+ read_only: bool = False,
+ selected_etudid: int = None,
+ mode="jury",
+) -> str:
+ """formsemestre est un semestre PAIR
+ Si readonly, ne montre pas le lien "saisir la décision"
+
+ => page html complète
+
+ Si mode == "recap", table recap des codes, sans liens de saisie.
+ """
+ # Quick & Dirty
+ # pour chaque etud de res2 trié
+ # S1: UE1, ..., UEn
+ # S2: UE1, ..., UEn
+ #
+ # UE1_s1, UE1_s2, moy_rcue, UE2... , Nbrcue_validables, Nbrcue<8, passage_de_droit, valide_moitie_rcue
+ #
+ # Pour chaque etud de res2 trié
+ # DecisionsProposeesAnnee(etud, formsemestre2)
+ # Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur
+ # -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue, etc
+ if formsemestre2.semestre_id % 2 != 0:
+ raise ScoValueError("Cette page ne fonctionne que sur les semestres pairs")
+
+ if formsemestre2.formation.referentiel_competence is None:
+ raise ScoValueError(
+ """
+
Pas de référentiel de compétences associé à la formation !
+
Pour associer un référentiel, passer par le menu Semestre /
+ Voir la formation... et suivre le lien "associer à un référentiel
+ de compétences"
+ """
+ )
+
+ rows, titles, column_ids = get_table_jury_but(
+ formsemestre2, read_only=read_only, mode=mode
+ )
+ if not rows:
+ return (
+ '
"""
+
+
+#
+def infos_fiche_etud_html(etudid: int) -> str:
+ """Section html pour fiche etudiant
+ provisoire pour BUT 2022
+ """
+ etud: Identite = Identite.query.get_or_404(etudid)
+ inscriptions = (
+ FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
+ .filter(
+ FormSemestreInscription.etudid == etud.id,
+ )
+ .order_by(FormSemestre.date_debut)
+ )
+ formsemestres_but = [
+ i.formsemestre for i in inscriptions if i.formsemestre.formation.is_apc()
+ ]
+ if len(formsemestres_but) == 0:
+ return ""
+
+ # temporaire quick & dirty: affiche le dernier
+ try:
+ deca = DecisionsProposeesAnnee(etud, formsemestres_but[-1])
+ if len(deca.rcues_annee) > 0:
+ return f"""
+ {show_etud(deca, read_only=True)}
+
+ """
+ except ScoValueError:
+ pass
+
+ return ""
diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py
index 6cf0767f0..88eb36fc9 100644
--- a/app/comp/bonus_spo.py
+++ b/app/comp/bonus_spo.py
@@ -767,6 +767,21 @@ class BonusStMalo(BonusIUTRennes1):
__doc__ = BonusIUTRennes1.__doc__
+class BonusLaRocheSurYon(BonusSportAdditif):
+ """Bonus IUT de La Roche-sur-Yon
+
+ Si une note de bonus est saisie, l'étudiant est gratifié de 0,2 points
+ sur sa moyenne générale ou, en BUT, sur la moyenne de chaque UE.
+ """
+
+ name = "bonus_larochesuryon"
+ displayed_name = "IUT de La Roche-sur-Yon"
+ seuil_moy_gen = 0.0
+ seuil_comptage = 0.0
+ proportion_point = 1e10 # le moindre point sature le bonus
+ bonus_max = 0.2 # à 0.2
+
+
class BonusLaRochelle(BonusSportAdditif):
"""Calcul bonus modules optionnels (sport, culture), règle IUT de La Rochelle.
@@ -1023,6 +1038,54 @@ class BonusNantes(BonusSportAdditif):
bonus_max = 0.5 # plafonnement à 0.5 points
+class BonusOrleans(BonusSportAdditif):
+ """Calcul bonus modules optionnels (sport, culture), règle IUT d'Orléans
+
Cadre général :
+ En reconnaissance de l'engagement des étudiants dans la vie associative,
+ sociale ou professionnelle, l’IUT d’Orléans accorde, sous conditions,
+ une bonification aux étudiants inscrits qui en font la demande en début
+ d’année universitaire.
+
+
Cet engagement doit être régulier et correspondre à une activité réelle
+ et sérieuse qui bénéficie à toute la communauté étudiante de l’IUT,
+ de l’Université ou à l’ensemble de la collectivité.
+
Bonification :
+ Pour les DUT et LP, cette bonification interviendra sur la moyenne générale
+ des semestres pairs :
+
du 2ème semestre pour les étudiants de 1ère année de DUT
+
du 4ème semestre pour les étudiants de 2nde année de DUT
+
du 6ème semestre pour les étudiants en LP
+
+ Pour le BUT, cette bonification interviendra sur la moyenne de chacune
+ des UE des semestre pairs :
+
du 2ème semestre pour les étudiants de 1ère année de BUT
+
du 4ème semestre pour les étudiants de 2ème année de BUT
+
du 6ème semestre pour les étudiants de 3ème année de BUT
+
+ La bonification ne peut dépasser +0,5 points par année universitaire.
+
+
Avant février 2020 :
+ Un bonus de 2,5% de la note de sport est accordé à la moyenne générale.
+
+ """
+
+ name = "bonus_iutorleans"
+ displayed_name = "IUT d'Orléans"
+ bonus_max = 0.5
+ seuil_moy_gen = 0.0 # seuls les points au dessus du seuil sont comptés
+ proportion_point = 1
+ classic_use_bonus_ues = False
+
+ def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
+ if self.formsemestre.date_debut > datetime.date(2020, 2, 1):
+ self.proportion_point = 1.0
+ else:
+ self.proportion_point = 2.5 / 100.0
+ return super().compute_bonus(
+ sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan
+ )
+
+
class BonusPoitiers(BonusSportAdditif):
"""Calcul bonus optionnels (sport, culture), règle IUT de Poitiers.
diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index 8f8bd1a89..aba032fd9 100644
--- a/app/comp/moy_mod.py
+++ b/app/comp/moy_mod.py
@@ -161,8 +161,11 @@ class ModuleImplResults:
evals_notes = evals_notes.merge(
eval_df, how="left", left_index=True, right_index=True
)
- # Notes en attente: (on prend dans evals_notes pour ne pas avoir les dem.)
- nb_att = sum(evals_notes[str(evaluation.id)] == scu.NOTES_ATTENTE)
+ # Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
+ nb_att = sum(
+ evals_notes[str(evaluation.id)][list(inscrits_module)]
+ == scu.NOTES_ATTENTE
+ )
self.evaluations_etat[evaluation.id] = EvaluationEtat(
evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete
)
diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py
index 2d337a3d0..5f432387c 100644
--- a/app/comp/moy_ue.py
+++ b/app/comp/moy_ue.py
@@ -496,17 +496,26 @@ def compute_malus(
"""
ues_idx = [ue.id for ue in ues]
malus = pd.DataFrame(index=modimpl_inscr_df.index, columns=ues_idx, dtype=float)
+ if len(sem_modimpl_moys.flat) == 0: # vide
+ return malus
+ if len(sem_modimpl_moys.shape) > 2:
+ # BUT: ne retient que la 1er composante du malus qui est scalaire
+ # au sens ou chaque note de malus n'affecte que la moyenne de l'UE
+ # de rattachement de son module.
+ sem_modimpl_moys_scalar = sem_modimpl_moys[:, :, 0]
+ else: # classic
+ sem_modimpl_moys_scalar = sem_modimpl_moys
for ue in ues:
if ue.type != UE_SPORT:
modimpl_mask = np.array(
[
(m.module.module_type == ModuleType.MALUS)
- and (m.module.ue.id == ue.id)
+ and (m.module.ue.id == ue.id) # UE de rattachement
for m in formsemestre.modimpls_sorted
]
)
if len(modimpl_mask):
- malus_moys = sem_modimpl_moys[:, modimpl_mask].sum(axis=1)
+ malus_moys = sem_modimpl_moys_scalar[:, modimpl_mask].sum(axis=1)
malus[ue.id] = malus_moys
malus.fillna(0.0, inplace=True)
diff --git a/app/comp/res_but.py b/app/comp/res_but.py
index d7fec7863..0b58521ae 100644
--- a/app/comp/res_but.py
+++ b/app/comp/res_but.py
@@ -6,6 +6,8 @@
"""Résultats semestres BUT
"""
+from collections.abc import Generator
+from re import U
import time
import numpy as np
import pandas as pd
@@ -28,6 +30,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
"modimpl_coefs_df",
"modimpls_evals_poids",
"sem_cube",
+ "etuds_parcour_id", # parcours de chaque étudiant
+ "ues_inscr_parcours_df", # inscriptions aux UE / parcours
)
def __init__(self, formsemestre):
@@ -35,7 +39,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.sem_cube = None
"""ndarray (etuds x modimpl x ue)"""
-
+ self.etuds_parcour_id = None
+ """Parcours de chaque étudiant { etudid : parcour_id }"""
if not self.load_cached():
t0 = time.time()
self.compute()
@@ -55,6 +60,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.modimpls_results,
) = moy_ue.notes_sem_load_cube(self.formsemestre)
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
+ self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
)
@@ -108,6 +114,9 @@ class ResultatsSemestreBUT(NotesTableCompat):
# Clippe toutes les moyennes d'UE dans [0,20]
self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True)
+ # Nanifie les moyennes d'UE hors parcours pour chaque étudiant
+ self.etud_moy_ue *= self.ues_inscr_parcours_df
+
# Moyenne générale indicative:
# (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte
# donc la moyenne indicative)
@@ -149,16 +158,24 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""
return self.modimpl_coefs_df.loc[ue.id].sum()
- def modimpls_in_ue(self, ue_id, etudid, with_bonus=True) -> list[ModuleImpl]:
+ def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]:
"""Liste des modimpl ayant des coefs non nuls vers cette UE
et auxquels l'étudiant est inscrit. Inclus modules bonus le cas échéant.
"""
# sert pour l'affichage ou non de l'UE sur le bulletin et la table recap
- coefs = self.modimpl_coefs_df # row UE, cols modimpl
+ if ue.type == UE_SPORT:
+ return [
+ modimpl
+ for modimpl in self.formsemestre.modimpls_sorted
+ if modimpl.module.ue.id == ue.id
+ and self.modimpl_inscr_df[modimpl.id][etudid]
+ ]
+ coefs = self.modimpl_coefs_df # row UE (sans bonus), cols modimpl
modimpls = [
modimpl
for modimpl in self.formsemestre.modimpls_sorted
- if (coefs[modimpl.id][ue_id] != 0)
+ if modimpl.module.ue.type != UE_SPORT
+ and (coefs[modimpl.id][ue.id] != 0)
and self.modimpl_inscr_df[modimpl.id][etudid]
]
if not with_bonus:
@@ -175,3 +192,50 @@ class ResultatsSemestreBUT(NotesTableCompat):
i = self.modimpl_coefs_df.columns.get_loc(modimpl_id)
j = self.modimpl_coefs_df.index.get_loc(ue_id)
return self.sem_cube[:, i, j]
+
+ def load_ues_inscr_parcours(self) -> pd.DataFrame:
+ """Chargement des inscriptions aux parcours et calcul de la
+ matrice d'inscriptions (etuds, ue).
+ S'il n'y pas de référentiel de compétence, donc pas de parcours,
+ on considère l'étudiant inscrit à toutes les ue.
+ La matrice avec ue ne comprend que les UE non bonus.
+ 1.0 si étudiant inscrit à l'UE, NaN sinon.
+ """
+ etuds_parcour_id = {
+ inscr.etudid: inscr.parcour_id for inscr in self.formsemestre.inscriptions
+ }
+ self.etuds_parcour_id = etuds_parcour_id
+ ue_ids = [ue.id for ue in self.ues if ue.type != UE_SPORT]
+ # matrice de 1, inscrits par défaut à toutes les UE:
+ ues_inscr_parcours_df = pd.DataFrame(
+ 1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
+ )
+ if self.formsemestre.formation.referentiel_competence is None:
+ return ues_inscr_parcours_df
+
+ ue_by_parcours = {} # parcours_id : {ue_id:0|1}
+ for parcour in self.formsemestre.formation.referentiel_competence.parcours:
+ ue_by_parcours[parcour.id] = {
+ ue.id: 1.0
+ for ue in self.formsemestre.formation.query_ues_parcour(
+ parcour
+ ).filter_by(semestre_idx=self.formsemestre.semestre_id)
+ }
+ for etudid in etuds_parcour_id:
+ parcour = etuds_parcour_id[etudid]
+ if parcour is not None:
+ ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[
+ etuds_parcour_id[etudid]
+ ]
+ return ues_inscr_parcours_df
+
+ def etud_ues_ids(self, etudid: int) -> list[int]:
+ """Liste des id d'UE auxquelles l'étudiant est inscrit (sans bonus).
+ (surchargée ici pour prendre en compte les parcours)
+ """
+ s = self.ues_inscr_parcours_df.loc[etudid]
+ return s.index[s.notna()]
+
+ def etud_ues(self, etudid: int) -> Generator[UniteEns]:
+ """Liste des UE auxquelles l'étudiant est inscrit."""
+ return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))
diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index 645f067e6..04bd92d13 100644
--- a/app/comp/res_common.py
+++ b/app/comp/res_common.py
@@ -112,6 +112,14 @@ class ResultatsSemestre(ResultatsCache):
"dict { etudid : indice dans les inscrits }"
return {e.id: idx for idx, e in enumerate(self.etuds)}
+ def etud_ues_ids(self, etudid: int) -> list[int]:
+ """Liste des UE auxquelles l'etudiant est inscrit, sans bonus
+ (surchargée en BUT pour prendre en compte les parcours)
+ """
+ # Pour les formations classiques, etudid n'est pas utilisé
+ # car tous les étudiants sont inscrits à toutes les UE
+ return [ue.id for ue in self.ues if ue.type != UE_SPORT]
+
def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray:
"""Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue.
Utile pour stats bottom tableau recap.
@@ -179,7 +187,7 @@ class ResultatsSemestre(ResultatsCache):
ues = sorted(list(ues), key=lambda x: x.numero or 0)
return ues
- def modimpls_in_ue(self, ue_id, etudid, with_bonus=True) -> list[ModuleImpl]:
+ def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]:
"""Liste des modimpl de cette UE auxquels l'étudiant est inscrit.
Utile en formations classiques, surchargée pour le BUT.
Inclus modules bonus le cas échéant.
@@ -189,7 +197,7 @@ class ResultatsSemestre(ResultatsCache):
modimpls = [
modimpl
for modimpl in self.formsemestre.modimpls_sorted
- if modimpl.module.ue.id == ue_id
+ if modimpl.module.ue.id == ue.id
and self.modimpl_inscr_df[modimpl.id][etudid]
]
if not with_bonus:
@@ -391,7 +399,7 @@ class ResultatsSemestre(ResultatsCache):
# --- TABLEAU RECAP
def get_table_recap(
- self, convert_values=False, include_evaluations=False, modejury=False
+ self, convert_values=False, include_evaluations=False, mode_jury=False
):
"""Result: tuple avec
- rows: liste de dicts { column_id : value }
@@ -542,7 +550,7 @@ class ResultatsSemestre(ResultatsCache):
titles_bot[
f"_{col_id}_target_attrs"
] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
- if modejury:
+ if mode_jury:
# pas d'autre colonnes de résultats
continue
# Bonus (sport) dans cette UE ?
@@ -564,7 +572,7 @@ class ResultatsSemestre(ResultatsCache):
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE
idx_malus = idx # place pour colonne malus à gauche des modules
idx += 1
- for modimpl in self.modimpls_in_ue(ue.id, etudid, with_bonus=False):
+ for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
if ue_status["is_capitalized"]:
val = "-c-"
else:
@@ -622,9 +630,10 @@ class ResultatsSemestre(ResultatsCache):
f"_{col_id}_target_attrs"
] = f""" title="{modimpl.module.titre} ({nom_resp})" """
modimpl_ids.add(modimpl.id)
+ nb_ues_etud_parcours = len(self.etud_ues_ids(etudid))
ue_valid_txt = (
ue_valid_txt_html
- ) = f"{nb_ues_validables}/{len(ues_sans_bonus)}"
+ ) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
if nb_ues_warning:
ue_valid_txt_html += " " + scu.EMO_WARNING
add_cell(
@@ -641,7 +650,17 @@ class ResultatsSemestre(ResultatsCache):
elif nb_ues_validables < len(ues_sans_bonus):
row["_ues_validables_class"] += " moy_inf"
row["_ues_validables_order"] = nb_ues_validables # pour tri
- if modejury:
+ if mode_jury:
+ dec_sem = self.validations.decisions_jury.get(etudid)
+ jury_code_sem = dec_sem["code"] if dec_sem else ""
+ idx = add_cell(
+ row,
+ "jury_code_sem",
+ "Jury",
+ jury_code_sem,
+ "jury_code_sem",
+ 1000,
+ )
idx = add_cell(
row,
"jury_link",
@@ -651,11 +670,11 @@ class ResultatsSemestre(ResultatsCache):
)
}">saisir décision""",
"col_jury_link",
- 1000,
+ idx,
)
rows.append(row)
- self._recap_add_partitions(rows, titles)
+ self.recap_add_partitions(rows, titles)
self._recap_add_admissions(rows, titles)
# tri par rang croissant
@@ -762,7 +781,9 @@ class ResultatsSemestre(ResultatsCache):
"apo": row_apo,
}
- def _recap_etud_groups_infos(self, etudid: int, row: dict, titles: dict):
+ def _recap_etud_groups_infos(
+ self, etudid: int, row: dict, titles: dict
+ ): # XXX non utilisé
"""Table recap: ajoute à row les colonnes sur les groupes pour cet etud"""
# dec = self.get_etud_decision_sem(etudid)
# if dec:
@@ -818,7 +839,7 @@ class ResultatsSemestre(ResultatsCache):
else:
row[f"_{cid}_class"] = "admission"
- def _recap_add_partitions(self, rows: list[dict], titles: dict):
+ def recap_add_partitions(self, rows: list[dict], titles: dict, col_idx: int = None):
"""Ajoute les colonnes indiquant les groupes
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "partition"
@@ -827,7 +848,7 @@ class ResultatsSemestre(ResultatsCache):
self.formsemestre.id
)
first_partition = True
- col_order = 10
+ col_order = 10 if col_idx is None else col_idx
for partition in partitions:
cid = f"part_{partition['partition_id']}"
rg_cid = cid + "_rg" # rang dans la partition
diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py
index 5ac18ff4e..d48c727de 100644
--- a/app/comp/res_compat.py
+++ b/app/comp/res_compat.py
@@ -54,6 +54,7 @@ class NotesTableCompat(ResultatsSemestre):
self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}}
self.expr_diagnostics = ""
self.parcours = self.formsemestre.formation.get_parcours()
+ self._modimpls_dict_by_ue = {} # local cache
def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]:
"""Liste des étudiants inscrits
@@ -145,6 +146,10 @@ class NotesTableCompat(ResultatsSemestre):
"""Liste des modules pour une UE (ou toutes si ue_id==None),
triés par numéros (selon le type de formation)
"""
+ # cached ?
+ modimpls_dict = self._modimpls_dict_by_ue.get(ue_id)
+ if modimpls_dict:
+ return modimpls_dict
modimpls_dict = []
for modimpl in self.formsemestre.modimpls_sorted:
if (ue_id is None) or (modimpl.module.ue.id == ue_id):
@@ -152,6 +157,7 @@ class NotesTableCompat(ResultatsSemestre):
# compat ScoDoc < 9.2: ajoute matières
d["mat"] = modimpl.module.matiere.to_dict()
modimpls_dict.append(d)
+ self._modimpls_dict_by_ue[ue_id] = modimpls_dict
return modimpls_dict
def compute_rangs(self):
diff --git a/app/decorators.py b/app/decorators.py
index d6c6ed234..83441275e 100644
--- a/app/decorators.py
+++ b/app/decorators.py
@@ -87,10 +87,10 @@ def permission_required(permission):
def decorated_function(*args, **kwargs):
scodoc_dept = getattr(g, "scodoc_dept", None)
if not current_user.has_permission(permission, scodoc_dept):
- abort(403)
+ return current_app.login_manager.unauthorized()
return f(*args, **kwargs)
- return login_required(decorated_function)
+ return decorated_function
return decorator
diff --git a/app/forms/main/config_apo.py b/app/forms/main/config_apo.py
index 9a5e11989..946e6ff29 100644
--- a/app/forms/main/config_apo.py
+++ b/app/forms/main/config_apo.py
@@ -41,6 +41,7 @@ from app.scodoc import sco_codes_parcours
def _build_code_field(code):
return StringField(
label=code,
+ default=code,
description=sco_codes_parcours.CODES_EXPL[code],
validators=[
validators.regexp(
@@ -58,6 +59,8 @@ def _build_code_field(code):
class CodesDecisionsForm(FlaskForm):
"Formulaire code décisions Apogée"
+ ABAN = _build_code_field("ABAN")
+ ABL = _build_code_field("ABL")
ADC = _build_code_field("ADC")
ADJ = _build_code_field("ADJ")
ADM = _build_code_field("ADM")
@@ -68,8 +71,13 @@ class CodesDecisionsForm(FlaskForm):
CMP = _build_code_field("CMP")
DEF = _build_code_field("DEF")
DEM = _build_code_field("DEM")
+ EXCLU = _build_code_field("EXCLU")
NAR = _build_code_field("NAR")
+ PASD = _build_code_field("PASD")
+ PAS1NCI = _build_code_field("PAS1NCI")
RAT = _build_code_field("RAT")
+ RED = _build_code_field("RED")
+
NOTES_FMT = StringField(
label="Format notes exportées",
description="""Format des notes. Par défaut %3.2f (deux chiffres après la virgule)""",
diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py
index 0d0ac0d5a..b35ac34e1 100644
--- a/app/forms/main/config_logos.py
+++ b/app/forms/main/config_logos.py
@@ -43,7 +43,7 @@ from app.scodoc import sco_logos, html_sco_header
from app.scodoc import sco_utils as scu
from app.scodoc.sco_config_actions import LogoInsert
-
+from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_logos import find_logo
@@ -108,6 +108,8 @@ def dept_key_to_id(dept_key):
def logo_name_validator(message=None):
def validate_logo_name(form, field):
name = field.data if field.data else ""
+ if "." in name:
+ raise ValidationError(message)
if not scu.is_valid_filename(name):
raise ValidationError(message)
@@ -199,9 +201,12 @@ class LogoForm(FlaskForm):
def __init__(self, *args, **kwargs):
kwargs["meta"] = {"csrf": False}
super().__init__(*args, **kwargs)
- self.logo = find_logo(
+ logo = find_logo(
logoname=self.logo_id.data, dept_id=dept_key_to_id(self.dept_key.data)
- ).select()
+ )
+ if logo is None:
+ raise ScoValueError("logo introuvable")
+ self.logo = logo.select()
self.description = None
self.titre = None
self.can_delete = True
diff --git a/app/models/__init__.py b/app/models/__init__.py
index d29b6bf3b..c7a183ec3 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -1,14 +1,25 @@
# -*- coding: UTF-8 -*
"""Modèles base de données ScoDoc
-XXX version préliminaire ScoDoc8 #sco8 sans département
"""
+import sqlalchemy
+
CODE_STR_LEN = 16 # chaine pour les codes
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
APO_CODE_STR_LEN = 512 # nb de car max d'un code Apogée (il peut y en avoir plusieurs)
GROUPNAME_STR_LEN = 64
+convention = {
+ "ix": "ix_%(column_0_label)s",
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
+ "pk": "pk_%(table_name)s",
+}
+
+metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
+
from app.models.raw_sql_init import create_database_functions
from app.models.absences import Absence, AbsenceNotification, BilletAbsence
@@ -65,5 +76,8 @@ from app.models.but_refcomp import (
ApcCompetence,
ApcSituationPro,
ApcAppCritique,
+ ApcParcours,
)
+from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
+
from app.models.config import ScoDocSiteConfig
diff --git a/app/models/absences.py b/app/models/absences.py
index 830d46f9e..405ea6bff 100644
--- a/app/models/absences.py
+++ b/app/models/absences.py
@@ -11,7 +11,9 @@ class Absence(db.Model):
__tablename__ = "absences"
id = db.Column(db.Integer, primary_key=True)
- etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), index=True)
+ etudid = db.Column(
+ db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
+ )
jour = db.Column(db.Date)
estabs = db.Column(db.Boolean())
estjust = db.Column(db.Boolean())
@@ -50,7 +52,7 @@ class AbsenceNotification(db.Model):
id = db.Column(db.Integer, primary_key=True)
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
)
notification_date = db.Column(
db.DateTime(timezone=True), server_default=db.func.now()
diff --git a/app/models/but_pn.py b/app/models/but_pn.py
index 35afbe16c..67993d023 100644
--- a/app/models/but_pn.py
+++ b/app/models/but_pn.py
@@ -1,12 +1,9 @@
"""ScoDoc 9 models : Formation BUT 2021
+ XXX inutilisé
"""
-from enum import unique
-from typing import Any
from app import db
-from app.scodoc.sco_utils import ModuleType
-
class APCFormation(db.Model):
"""Formation par compétence"""
diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py
index 0750f1a9f..b9626f828 100644
--- a/app/models/but_refcomp.py
+++ b/app/models/but_refcomp.py
@@ -7,12 +7,14 @@
"""
from datetime import datetime
+import flask_sqlalchemy
from sqlalchemy.orm import class_mapper
import sqlalchemy
from app import db
from app.scodoc.sco_utils import ModuleType
+from app.scodoc.sco_exceptions import ScoValueError
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
@@ -82,7 +84,7 @@ class ApcReferentielCompetences(db.Model, XMLModel):
formations = db.relationship("Formation", backref="referentiel_competence")
def __repr__(self):
- return f""
+ return f""
def to_dict(self):
"""Représentation complète du ref. de comp.
@@ -105,6 +107,52 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"parcours": {x.code: x.to_dict() for x in self.parcours},
}
+ def get_niveaux_by_parcours(self, annee) -> dict:
+ """
+ Construit la liste des niveaux de compétences pour chaque parcours
+ de ce référentiel.
+ Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
+ Le tronc commun n'est pas identifié comme tel dans les référentiels Orébut:
+ on cherche les niveaux qui sont présents dans tous les parcours et les range sous
+ la clé "TC" (toujours présente mais éventuellement liste vide si pas de tronc commun).
+
+ résultat:
+ {
+ "TC" : [ ApcNiveau ],
+ parcour.id : [ ApcNiveau ]
+ }
+ """
+ parcours = self.parcours.order_by(ApcParcours.numero).all()
+ niveaux_by_parcours = {
+ parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
+ for parcour in parcours
+ }
+ # Cherche tronc commun
+ niveaux_ids_tc = set.intersection(
+ *[
+ {n.id for n in niveaux_by_parcours[parcour_id]}
+ for parcour_id in niveaux_by_parcours
+ ]
+ )
+ # Enleve les niveaux du tronc commun
+ niveaux_by_parcours_no_tc = {
+ parcour.id: [
+ niveau
+ for niveau in niveaux_by_parcours[parcour.id]
+ if niveau.id not in niveaux_ids_tc
+ ]
+ for parcour in parcours
+ }
+ # Niveaux du TC
+ niveaux_tc = []
+ if len(parcours):
+ niveaux_parcours_1 = niveaux_by_parcours[parcours[0].id]
+ niveaux_tc = [
+ niveau for niveau in niveaux_parcours_1 if niveau.id in niveaux_ids_tc
+ ]
+ niveaux_by_parcours_no_tc["TC"] = niveaux_tc
+ return niveaux_by_parcours_no_tc
+
class ApcCompetence(db.Model, XMLModel):
"Compétence"
@@ -144,9 +192,10 @@ class ApcCompetence(db.Model, XMLModel):
)
def __repr__(self):
- return f""
+ return f""
def to_dict(self):
+ "repr dict recursive sur situations, composantes, niveaux"
return {
"id_orebut": self.id_orebut,
"titre": self.titre,
@@ -160,6 +209,16 @@ class ApcCompetence(db.Model, XMLModel):
"niveaux": {x.annee: x.to_dict() for x in self.niveaux},
}
+ def to_dict_bul(self) -> dict:
+ "dict court pour bulletins"
+ return {
+ "id_orebut": self.id_orebut,
+ "titre": self.titre,
+ "titre_long": self.titre_long,
+ "couleur": self.couleur,
+ "numero": self.numero,
+ }
+
class ApcSituationPro(db.Model, XMLModel):
"Situation professionnelle"
@@ -186,13 +245,20 @@ class ApcComposanteEssentielle(db.Model, XMLModel):
class ApcNiveau(db.Model, XMLModel):
+ """Niveau de compétence
+ Chaque niveau peut être associé à deux UE,
+ des semestres impair et pair de la même année.
+ """
+
+ __tablename__ = "apc_niveau"
+
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
)
libelle = db.Column(db.Text(), nullable=False)
- annee = db.Column(db.Text(), nullable=False) # "BUT2"
- # L'ordre est l'année d'apparition de ce niveau
+ annee = db.Column(db.Text(), nullable=False) # "BUT1", "BUT2", "BUT3"
+ # L'ordre est le niveau (1,2,3) ou (1,2) suivant la competence
ordre = db.Column(db.Integer, nullable=False) # 1, 2, 3
app_critiques = db.relationship(
"ApcAppCritique",
@@ -200,11 +266,14 @@ class ApcNiveau(db.Model, XMLModel):
lazy="dynamic",
cascade="all, delete-orphan",
)
+ ues = db.relationship("UniteEns", back_populates="niveau_competence")
def __repr__(self):
- return f"<{self.__class__.__name__} ordre={self.ordre}>"
+ return f"""<{self.__class__.__name__} ordre={self.ordre!r} annee={
+ self.annee!r} {self.competence!r}>"""
def to_dict(self):
+ "as a dict, recursif sur les AC"
return {
"libelle": self.libelle,
"annee": self.annee,
@@ -212,6 +281,64 @@ class ApcNiveau(db.Model, XMLModel):
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques},
}
+ def to_dict_bul(self):
+ "dict pour bulletins: indique la compétence, pas les ACs (pour l'instant ?)"
+ return {
+ "libelle": self.libelle,
+ "annee": self.annee,
+ "ordre": self.ordre,
+ "competence": self.competence.to_dict_bul(),
+ }
+
+ @classmethod
+ def niveaux_annee_de_parcours(
+ cls,
+ parcour: "ApcParcours",
+ annee: int,
+ referentiel_competence: ApcReferentielCompetences = None,
+ ) -> flask_sqlalchemy.BaseQuery:
+ """Les niveaux de l'année du parcours
+ Si le parcour est None, tous les niveaux de l'année
+ """
+ if annee not in {1, 2, 3}:
+ raise ValueError("annee invalide pour un parcours BUT")
+ if referentiel_competence is None:
+ raise ScoValueError(
+ "Pas de référentiel de compétences associé à la formation !"
+ )
+ annee_formation = f"BUT{annee}"
+ if parcour is None:
+ return ApcNiveau.query.filter(
+ ApcNiveau.annee == annee_formation,
+ ApcCompetence.id == ApcNiveau.competence_id,
+ ApcCompetence.referentiel_id == referentiel_competence.id,
+ )
+ else:
+ return ApcNiveau.query.filter(
+ ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
+ ApcParcours.id == ApcAnneeParcours.parcours_id,
+ ApcParcours.referentiel == parcour.referentiel,
+ ApcParcoursNiveauCompetence.competence_id == ApcCompetence.id,
+ ApcCompetence.id == ApcNiveau.competence_id,
+ ApcAnneeParcours.parcours == parcour,
+ ApcNiveau.annee == annee_formation,
+ )
+
+
+app_critiques_modules = db.Table(
+ "apc_modules_acs",
+ db.Column(
+ "module_id",
+ db.ForeignKey("notes_modules.id", ondelete="CASCADE"),
+ primary_key=True,
+ ),
+ db.Column(
+ "app_crit_id",
+ db.ForeignKey("apc_app_critique.id"),
+ primary_key=True,
+ ),
+)
+
class ApcAppCritique(db.Model, XMLModel):
"Apprentissage Critique BUT"
@@ -220,12 +347,31 @@ class ApcAppCritique(db.Model, XMLModel):
code = db.Column(db.Text(), nullable=False, index=True)
libelle = db.Column(db.Text())
- modules = db.relationship(
- "Module",
- secondary="apc_modules_acs",
- lazy="dynamic",
- backref=db.backref("app_critiques", lazy="dynamic"),
- )
+ # modules = db.relationship(
+ # "Module",
+ # secondary="apc_modules_acs",
+ # lazy="dynamic",
+ # backref=db.backref("app_critiques", lazy="dynamic"),
+ # )
+
+ @classmethod
+ def app_critiques_ref_comp(
+ cls,
+ ref_comp: ApcReferentielCompetences,
+ annee: str,
+ competence: ApcCompetence = None,
+ ) -> flask_sqlalchemy.BaseQuery:
+ "Liste les AC de tous les parcours de ref_comp pour l'année indiquée"
+ assert annee in {"BUT1", "BUT2", "BUT3"}
+ query = cls.query.filter(
+ ApcAppCritique.niveau_id == ApcNiveau.id,
+ ApcNiveau.competence_id == ApcCompetence.id,
+ ApcNiveau.annee == annee,
+ ApcCompetence.referentiel_id == ref_comp.id,
+ )
+ if competence is not None:
+ query = query.filter(ApcNiveau.competence == competence)
+ return query
def to_dict(self) -> dict:
return {"libelle": self.libelle}
@@ -234,18 +380,40 @@ class ApcAppCritique(db.Model, XMLModel):
return self.code + " - " + self.titre
def __repr__(self):
- return f"<{self.__class__.__name__} {self.code}>"
+ return f"<{self.__class__.__name__} {self.code!r}>"
def get_saes(self):
"""Liste des SAE associées"""
return [m for m in self.modules if m.module_type == ModuleType.SAE]
-ApcAppCritiqueModules = db.Table(
- "apc_modules_acs",
- db.Column("module_id", db.ForeignKey("notes_modules.id")),
- db.Column("app_crit_id", db.ForeignKey("apc_app_critique.id")),
+parcours_modules = db.Table(
+ "parcours_modules",
+ db.Column(
+ "parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
+ ),
+ db.Column(
+ "module_id",
+ db.Integer,
+ db.ForeignKey("notes_modules.id", ondelete="CASCADE"),
+ primary_key=True,
+ ),
)
+"""Association parcours <-> modules (many-to-many)"""
+
+parcours_formsemestre = db.Table(
+ "parcours_formsemestre",
+ db.Column(
+ "parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
+ ),
+ db.Column(
+ "formsemestre_id",
+ db.Integer,
+ db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"),
+ primary_key=True,
+ ),
+)
+"""Association parcours <-> formsemestre (many-to-many)"""
class ApcParcours(db.Model, XMLModel):
@@ -264,7 +432,7 @@ class ApcParcours(db.Model, XMLModel):
)
def __repr__(self):
- return f"<{self.__class__.__name__} {self.code}>"
+ return f"<{self.__class__.__name__} {self.code!r}>"
def to_dict(self):
return {
@@ -281,9 +449,10 @@ class ApcAnneeParcours(db.Model, XMLModel):
db.Integer, db.ForeignKey("apc_parcours.id"), nullable=False
)
ordre = db.Column(db.Integer)
+ "numéro de l'année: 1, 2, 3"
def __repr__(self):
- return f"<{self.__class__.__name__} ordre={self.ordre}>"
+ return f"<{self.__class__.__name__} ordre={self.ordre!r} parcours={self.parcours.code!r}>"
def to_dict(self):
return {
@@ -321,6 +490,7 @@ class ApcParcoursNiveauCompetence(db.Model):
"annee_parcours",
passive_deletes=True,
cascade="save-update, merge, delete, delete-orphan",
+ lazy="dynamic",
),
)
annee_parcours = db.relationship(
@@ -333,4 +503,4 @@ class ApcParcoursNiveauCompetence(db.Model):
)
def __repr__(self):
- return f"<{self.__class__.__name__} {self.competence} {self.annee_parcours}>"
+ return f"<{self.__class__.__name__} {self.competence!r}<->{self.annee_parcours!r} niveau={self.niveau!r}>"
diff --git a/app/models/but_validations.py b/app/models/but_validations.py
new file mode 100644
index 000000000..6c2814424
--- /dev/null
+++ b/app/models/but_validations.py
@@ -0,0 +1,329 @@
+# -*- coding: UTF-8 -*
+
+"""Décisions de jury (validations) des RCUE et années du BUT
+"""
+
+import flask_sqlalchemy
+from sqlalchemy.sql import text
+from typing import Union
+
+from app import db
+
+from app.models import CODE_STR_LEN
+from app.models.but_refcomp import ApcNiveau
+from app.models.etudiants import Identite
+from app.models.ues import UniteEns
+from app.models.formations import Formation
+from app.models.formsemestre import FormSemestre
+from app.scodoc import sco_codes_parcours as sco_codes
+
+
+class ApcValidationRCUE(db.Model):
+ """Validation des niveaux de compétences
+
+ aka "regroupements cohérents d'UE" dans le jargon BUT.
+
+ le formsemestre est celui du semestre PAIR du niveau de compétence
+ """
+
+ __tablename__ = "apc_validation_rcue"
+ # Assure unicité de la décision:
+ __table_args__ = (
+ db.UniqueConstraint("etudid", "formsemestre_id", "ue1_id", "ue2_id"),
+ )
+
+ id = db.Column(db.Integer, primary_key=True)
+ etudid = db.Column(
+ db.Integer,
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
+ index=True,
+ nullable=False,
+ )
+ formsemestre_id = db.Column(
+ db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
+ )
+ # Les deux UE associées à ce niveau:
+ ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
+ ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
+ # optionnel, le parcours dans lequel se trouve la compétence:
+ parcours_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), nullable=True)
+ date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
+ code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
+
+ etud = db.relationship("Identite", backref="apc_validations_rcues")
+ formsemestre = db.relationship("FormSemestre", backref="apc_validations_rcues")
+ ue1 = db.relationship("UniteEns", foreign_keys=ue1_id)
+ ue2 = db.relationship("UniteEns", foreign_keys=ue2_id)
+ parcour = db.relationship("ApcParcours")
+
+ def __repr__(self):
+ return f"<{self.__class__.__name__} {self.id} {self.etud} {self.ue1}/{self.ue2}:{self.code!r}>"
+
+ 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_bul(self) -> dict:
+ "Export dict pour bulletins"
+ return {"code": self.code, "niveau": self.niveau().to_dict_bul()}
+
+
+# Attention: ce n'est pas un modèle mais une classe ordinaire:
+class RegroupementCoherentUE:
+ """Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
+ de la même année (BUT1,2,3) liées au *même niveau de compétence*.
+
+ La moyenne (10/20) au RCU déclenche la compensation des UE.
+ """
+
+ def __init__(
+ self,
+ etud: Identite,
+ formsemestre_1: FormSemestre,
+ ue_1: UniteEns,
+ formsemestre_2: FormSemestre,
+ ue_2: UniteEns,
+ ):
+ from app.comp import res_sem
+ from app.comp.res_but import ResultatsSemestreBUT
+
+ # Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
+ if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
+ (ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
+ (
+ ue_2,
+ formsemestre_2,
+ ),
+ (ue_1, formsemestre_1),
+ )
+ assert formsemestre_1.semestre_id % 2 == 1
+ assert formsemestre_2.semestre_id % 2 == 0
+ assert abs(formsemestre_1.semestre_id - formsemestre_2.semestre_id) == 1
+ assert ue_1.niveau_competence_id == ue_2.niveau_competence_id
+ self.etud = etud
+ self.formsemestre_1 = formsemestre_1
+ "semestre impair"
+ self.ue_1 = ue_1
+ self.formsemestre_2 = formsemestre_2
+ "semestre pair"
+ self.ue_2 = ue_2
+ # Stocke les moyennes d'UE
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1)
+ if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]:
+ self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id]
+ self.moy_ue_1_val = self.moy_ue_1 # toujours float, peut être NaN
+ else:
+ self.moy_ue_1 = None
+ self.moy_ue_1_val = 0.0
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_2)
+ if ue_2.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_2.id]:
+ self.moy_ue_2 = res.etud_moy_ue[ue_2.id][etud.id]
+ self.moy_ue_2_val = self.moy_ue_2
+ else:
+ self.moy_ue_2 = None
+ self.moy_ue_2_val = 0.0
+ # Calcul de la moyenne au RCUE
+ if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
+ # Moyenne RCUE (les pondérations par défaut sont 1.)
+ self.moy_rcue = (
+ self.moy_ue_1 * ue_1.coef_rcue + self.moy_ue_2 * ue_2.coef_rcue
+ ) / (ue_1.coef_rcue + ue_2.coef_rcue)
+ else:
+ self.moy_rcue = None
+
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__name__} {self.ue_1.acronyme}({self.moy_ue_1}) {self.ue_2.acronyme}({self.moy_ue_2})>"
+
+ def query_validations(
+ self,
+ ) -> flask_sqlalchemy.BaseQuery: # list[ApcValidationRCUE]
+ """Les validations de jury enregistrées pour ce RCUE"""
+ niveau = self.ue_2.niveau_competence
+
+ return (
+ ApcValidationRCUE.query.filter_by(
+ etudid=self.etud.id,
+ )
+ .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
+ .join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
+ .filter(ApcNiveau.id == niveau.id)
+ )
+
+ def other_ue(self, ue: UniteEns) -> UniteEns:
+ """L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
+ if ue.id == self.ue_1.id:
+ return self.ue_2
+ elif ue.id == self.ue_2.id:
+ return self.ue_1
+ raise ValueError(f"ue {ue} hors RCUE {self}")
+
+ def est_enregistre(self) -> bool:
+ """Vrai si ce RCUE, donc le niveau de compétences correspondant
+ a une décision jury enregistrée
+ """
+ return self.query_validations().count() > 0
+
+ def est_compensable(self):
+ """Vrai si ce RCUE est validable par compensation
+ c'est à dire que sa moyenne est > 10 avec une UE < 10
+ """
+ return (
+ (self.moy_rcue is not None)
+ and (self.moy_rcue > sco_codes.BUT_BARRE_RCUE)
+ and (
+ (self.moy_ue_1_val < sco_codes.NOTES_BARRE_GEN)
+ or (self.moy_ue_2_val < sco_codes.NOTES_BARRE_GEN)
+ )
+ )
+
+ def est_suffisant(self) -> bool:
+ """Vrai si ce RCUE est > 8"""
+ return (self.moy_rcue is not None) and (
+ self.moy_rcue > sco_codes.BUT_RCUE_SUFFISANT
+ )
+
+ def est_validable(self) -> bool:
+ """Vrai si ce RCU satisfait les conditions pour être validé
+ Pour cela, il suffit que la moyenne des UE qui le constitue soit > 10
+ """
+ return (self.moy_rcue is not None) and (
+ self.moy_rcue > sco_codes.BUT_BARRE_RCUE
+ )
+
+ def code_valide(self) -> Union[ApcValidationRCUE, None]:
+ "Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None"
+ validation = self.query_validations().first()
+ if (validation is not None) and (
+ validation.code in sco_codes.CODES_RCUE_VALIDES
+ ):
+ return validation
+ return None
+
+
+def find_rcues(
+ formsemestre: FormSemestre, ue: UniteEns, etud: Identite
+) -> list[RegroupementCoherentUE]:
+ """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
+ ce semestre pour cette UE.
+
+ Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
+ En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
+
+ Résultat: la liste peut être vide.
+ """
+ if (ue.niveau_competence is None) or (ue.semestre_idx is None):
+ return []
+
+ if ue.semestre_idx % 2: # S1, S3, S5
+ other_semestre_idx = ue.semestre_idx + 1
+ else:
+ other_semestre_idx = ue.semestre_idx - 1
+
+ cursor = db.session.execute(
+ text(
+ """SELECT
+ ue.id, formsemestre.id
+ FROM
+ notes_ue ue,
+ notes_formsemestre_inscription inscr,
+ notes_formsemestre formsemestre
+
+ WHERE
+ inscr.etudid = :etudid
+ AND inscr.formsemestre_id = formsemestre.id
+
+ AND formsemestre.semestre_id = :other_semestre_idx
+ AND ue.formation_id = formsemestre.formation_id
+ AND ue.niveau_competence_id = :ue_niveau_competence_id
+ AND ue.semestre_idx = :other_semestre_idx
+ """
+ ),
+ {
+ "etudid": etud.id,
+ "other_semestre_idx": other_semestre_idx,
+ "ue_niveau_competence_id": ue.niveau_competence_id,
+ },
+ )
+ rcues = []
+ for ue_id, formsemestre_id in cursor:
+ other_ue = UniteEns.query.get(ue_id)
+ other_formsemestre = FormSemestre.query.get(formsemestre_id)
+ rcues.append(
+ RegroupementCoherentUE(etud, formsemestre, ue, other_formsemestre, other_ue)
+ )
+ # safety check: 1 seul niveau de comp. concerné:
+ assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
+ return rcues
+
+
+class ApcValidationAnnee(db.Model):
+ """Validation des années du BUT"""
+
+ __tablename__ = "apc_validation_annee"
+ # Assure unicité de la décision:
+ __table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire"),)
+ id = db.Column(db.Integer, primary_key=True)
+ etudid = db.Column(
+ db.Integer,
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
+ index=True,
+ nullable=False,
+ )
+ ordre = db.Column(db.Integer, nullable=False)
+ "numéro de l'année: 1, 2, 3"
+ formsemestre_id = db.Column(
+ db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
+ )
+ "le semestre IMPAIR (le 1er) de l'année"
+ annee_scolaire = db.Column(db.Integer, nullable=False) # 2021
+ date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
+ code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
+
+ etud = db.relationship("Identite", backref="apc_validations_annees")
+ formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees")
+
+ def __repr__(self):
+ return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"
+
+ def to_dict_bul(self) -> dict:
+ "dict pour bulletins"
+ return {
+ "annee_scolaire": self.annee_scolaire,
+ "date": self.date.isoformat(),
+ "code": self.code,
+ "ordre": self.ordre,
+ }
+
+
+def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
+ """
+ Un dict avec les décisions de jury BUT enregistrées.
+ Ne reprend pas les décisions d'UE, non spécifiques au BUT.
+ """
+ decisions = {}
+ # --- RCUEs: seulement sur semestres pairs XXX à améliorer
+ if formsemestre.semestre_id % 2 == 0:
+ # validations émises depuis ce formsemestre:
+ validations_rcues = ApcValidationRCUE.query.filter_by(
+ etudid=etud.id, formsemestre_id=formsemestre.id
+ )
+ decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues]
+ else:
+ decisions["decision_rcue"] = []
+ # --- Année: prend la validation pour l'année scolaire de ce semestre
+ validation = (
+ ApcValidationAnnee.query.filter_by(
+ etudid=etud.id,
+ annee_scolaire=formsemestre.annee_scolaire(),
+ )
+ .join(ApcValidationAnnee.formsemestre)
+ .join(FormSemestre.formation)
+ .filter(Formation.formation_code == formsemestre.formation.formation_code)
+ .first()
+ )
+ if validation:
+ decisions["decision_annee"] = validation.to_dict_bul()
+ else:
+ decisions["decision_annee"] = None
+ return decisions
diff --git a/app/models/config.py b/app/models/config.py
index 53ac96e9b..cb65d5198 100644
--- a/app/models/config.py
+++ b/app/models/config.py
@@ -9,6 +9,8 @@ from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_codes_parcours import (
+ ABAN,
+ ABL,
ADC,
ADJ,
ADM,
@@ -19,11 +21,17 @@ from app.scodoc.sco_codes_parcours import (
CMP,
DEF,
DEM,
+ EXCLU,
NAR,
+ PASD,
+ PAS1NCI,
RAT,
+ RED,
)
CODES_SCODOC_TO_APO = {
+ ABAN: "ABAN",
+ ABL: "ABL",
ADC: "ADMC",
ADJ: "ADM",
ADM: "ADM",
@@ -34,8 +42,12 @@ CODES_SCODOC_TO_APO = {
CMP: "COMP",
DEF: "NAR",
DEM: "NAR",
+ EXCLU: "EXC",
NAR: "NAR",
+ PASD: "PASD",
+ PAS1NCI: "PAS1NCI",
RAT: "ATT",
+ RED: "RED",
"NOTES_FMT": "%3.2f",
}
@@ -161,9 +173,8 @@ class ScoDocSiteConfig(db.Model):
@classmethod
def get_code_apo(cls, code: str) -> str:
"""La représentation d'un code pour les exports Apogée.
- Par exemple, à l'iUT du H., le code ADM est réprésenté par VAL
+ Par exemple, à l'IUT du H., le code ADM est réprésenté par VAL
Les codes par défaut sont donnés dans sco_apogee_csv.
-
"""
cfg = ScoDocSiteConfig.query.filter_by(name=code).first()
if not cfg:
@@ -172,6 +183,11 @@ class ScoDocSiteConfig(db.Model):
code_apo = cfg.value
return code_apo
+ @classmethod
+ def get_codes_apo_dict(cls) -> dict[str:str]:
+ "Un dict avec code jury : code exporté"
+ return {code: cls.get_code_apo(code) for code in CODES_SCODOC_TO_APO}
+
@classmethod
def set_code_apo(cls, code: str, code_apo: str):
"""Enregistre nouvelle représentation du code"""
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 0bce6d47e..30333a6b1 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -60,7 +60,9 @@ class Identite(db.Model):
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
def __repr__(self):
- return f""
+ return (
+ f""
+ )
@classmethod
def from_request(cls, etudid=None, code_nip=None):
@@ -133,8 +135,10 @@ class Identite(db.Model):
def sort_key(self) -> tuple:
"clé pour tris par ordre alphabétique"
return (
- scu.suppress_accents(self.nom_usuel or self.nom or "").lower(),
- scu.suppress_accents(self.prenom or "").lower(),
+ scu.sanitize_string(
+ self.nom_usuel or self.nom or "", remove_spaces=False
+ ).lower(),
+ scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(),
)
def get_first_email(self, field="email") -> str:
@@ -201,6 +205,19 @@ class Identite(db.Model):
d.update(adresse.to_dict(convert_nulls_to_str=True))
return d
+ def inscriptions(self) -> list["FormSemestreInscription"]:
+ "Liste des inscriptions à des formsemestres, triée, la plus récente en tête"
+ from app.models.formsemestre import FormSemestre, FormSemestreInscription
+
+ return (
+ FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
+ .filter(
+ FormSemestreInscription.etudid == self.id,
+ )
+ .order_by(desc(FormSemestre.date_debut))
+ .all()
+ )
+
def inscription_courante(self):
"""La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore).
@@ -212,7 +229,7 @@ class Identite(db.Model):
]
return r[0] if r else None
- def inscriptions_courantes(self) -> list: # -> list[FormSemestreInscription]:
+ def inscriptions_courantes(self) -> list["FormSemestreInscription"]:
"""Liste des inscriptions à des semestres _courants_
(il est rare qu'il y en ai plus d'une, mais c'est possible).
Triées par date de début de semestre décroissante (le plus récent en premier).
@@ -240,18 +257,6 @@ class Identite(db.Model):
]
return r[0] if r else None
- def inscription_etat(self, formsemestre_id):
- """État de l'inscription de cet étudiant au semestre:
- False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF
- """
- # voir si ce n'est pas trop lent:
- ins = models.FormSemestreInscription.query.filter_by(
- etudid=self.id, formsemestre_id=formsemestre_id
- ).first()
- if ins:
- return ins.etat
- return False
-
def inscription_descr(self) -> dict:
"""Description de l'état d'inscription"""
inscription_courante = self.inscription_courante()
@@ -290,6 +295,18 @@ class Identite(db.Model):
"situation": situation,
}
+ def inscription_etat(self, formsemestre_id):
+ """État de l'inscription de cet étudiant au semestre:
+ False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF
+ """
+ # voir si ce n'est pas trop lent:
+ ins = models.FormSemestreInscription.query.filter_by(
+ etudid=self.id, formsemestre_id=formsemestre_id
+ ).first()
+ if ins:
+ return ins.etat
+ return False
+
def descr_situation_etud(self) -> str:
"""Chaîne décrivant la situation _actuelle_ de l'étudiant.
Exemple:
@@ -361,6 +378,15 @@ class Identite(db.Model):
return situation
+ def etat_civil_pv(self, line_sep="\n") -> str:
+ """Présentation, pour PV jury
+ M. Pierre Dupont
+ n° 12345678
+ né(e) le 7/06/1974
+ à Paris
+ """
+ return f"""{self.nomprenom}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{line_sep}à {self.lieu_naissance or ""}"""
+
def photo_html(self, title=None, size="small") -> str:
"""HTML img tag for the photo, either in small size (h90)
or original size (size=="orig")
@@ -434,7 +460,7 @@ class Adresse(db.Model):
adresse_id = db.synonym("id")
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
)
email = db.Column(db.Text()) # mail institutionnel
emailperso = db.Column(db.Text) # email personnel (exterieur)
@@ -468,7 +494,7 @@ class Admission(db.Model):
adm_id = db.synonym("id")
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
)
# Anciens champs de ScoDoc7, à revoir pour être plus générique et souple
# notamment dans le cadre du bac 2021
@@ -513,21 +539,21 @@ class Admission(db.Model):
def to_dict(self, no_nulls=False):
"""Représentation dictionnaire,"""
- e = dict(self.__dict__)
- e.pop("_sa_instance_state", None)
+ d = dict(self.__dict__)
+ d.pop("_sa_instance_state", None)
if no_nulls:
- for k in e:
- if e[k] is None:
+ for k in d.keys():
+ if d[k] is None:
col_type = getattr(
sqlalchemy.inspect(models.Admission).columns, "apb_groupe"
).expression.type
if isinstance(col_type, sqlalchemy.Text):
- e[k] = ""
+ d[k] = ""
elif isinstance(col_type, sqlalchemy.Integer):
- e[k] = 0
+ d[k] = 0
elif isinstance(col_type, sqlalchemy.Boolean):
- e[k] = False
- return e
+ d[k] = False
+ return d
# Suivi scolarité / débouchés
@@ -538,7 +564,7 @@ class ItemSuivi(db.Model):
itemsuivi_id = db.synonym("id")
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
)
item_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
situation = db.Column(db.Text)
diff --git a/app/models/events.py b/app/models/events.py
index b94549e76..4e566cbd9 100644
--- a/app/models/events.py
+++ b/app/models/events.py
@@ -32,6 +32,21 @@ class Scolog(db.Model):
authenticated_user = db.Column(db.Text) # login, sans contrainte
# zope_remote_addr suppressed
+ @classmethod
+ def logdb(
+ cls, method: str = None, etudid: int = None, msg: str = None, commit=False
+ ):
+ """Add entry in student's log (replacement for old scolog.logdb)"""
+ entry = Scolog(
+ method=method,
+ msg=msg,
+ etudid=etudid,
+ authenticated_user=current_user.user_name,
+ )
+ db.session.add(entry)
+ if commit:
+ db.session.commit()
+
class ScolarNews(db.Model):
"""Nouvelles pour page d'accueil"""
diff --git a/app/models/formations.py b/app/models/formations.py
index 43d3d680c..d2970fce9 100644
--- a/app/models/formations.py
+++ b/app/models/formations.py
@@ -1,15 +1,25 @@
"""ScoDoc 9 models : Formations
"""
+import flask_sqlalchemy
import app
from app import db
from app.comp import df_cache
from app.models import SHORT_STR_LEN
+from app.models.but_refcomp import (
+ ApcAnneeParcours,
+ ApcCompetence,
+ ApcNiveau,
+ ApcParcours,
+ ApcParcoursNiveauCompetence,
+)
from app.models.modules import Module
+from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns
from app.scodoc import sco_cache
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_utils as scu
+from app.scodoc.sco_codes_parcours import UE_STANDARD
class Formation(db.Model):
@@ -45,7 +55,11 @@ 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}')>"
+ return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>"
+
+ def to_html(self) -> str:
+ "titre complet pour affichage"
+ return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
def to_dict(self):
e = dict(self.__dict__)
@@ -55,7 +69,10 @@ class Formation(db.Model):
return e
def get_parcours(self):
- """get l'instance de TypeParcours de cette formation"""
+ """get l'instance de TypeParcours de cette formation
+ (le TypeParcours définit le genre de formation, à ne pas confondre
+ avec les parcours du BUT).
+ """
return sco_codes_parcours.get_parcours_from_code(self.type_parcours)
def get_titre_version(self) -> str:
@@ -97,6 +114,13 @@ class Formation(db.Model):
else:
keys = f"{self.id}.{semestre_idx}"
df_cache.ModuleCoefsCache.delete_many(keys | {f"{self.id}"})
+ # Invalidate aussi les poids de toutes les évals de la formation
+ for modimpl in ModuleImpl.query.filter(
+ ModuleImpl.module_id == Module.id,
+ Module.formation_id == self.id,
+ ):
+ modimpl.invalidate_evaluations_poids()
+
sco_cache.invalidate_formsemestre()
def invalidate_cached_sems(self):
@@ -148,6 +172,40 @@ class Formation(db.Model):
if change:
app.clear_scodoc_cache()
+ def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:
+ """Les UEs d'un parcours de la formation.
+ Exemple: pour avoir les UE du semestre 3, faire
+ `formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
+ """
+ return UniteEns.query.filter_by(formation=self).filter(
+ UniteEns.niveau_competence_id == ApcNiveau.id,
+ UniteEns.type == UE_STANDARD,
+ ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
+ ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
+ ApcAnneeParcours.parcours_id == parcour.id,
+ )
+
+ def query_competences_parcour(
+ self, parcour: ApcParcours
+ ) -> flask_sqlalchemy.BaseQuery:
+ """Les ApcCompetences d'un parcours de la formation.
+ None si pas de référentiel de compétences.
+ """
+ if self.referentiel_competence_id is None:
+ return None
+ return (
+ ApcCompetence.query.filter_by(referentiel_id=self.referentiel_competence_id)
+ .join(
+ ApcParcoursNiveauCompetence,
+ ApcParcoursNiveauCompetence.competence_id == ApcCompetence.id,
+ )
+ .join(
+ ApcAnneeParcours,
+ ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
+ )
+ .filter(ApcAnneeParcours.parcours_id == parcour.id)
+ )
+
class Matiere(db.Model):
"""Matières: regroupe les modules d'une UE
@@ -168,7 +226,7 @@ class Matiere(db.Model):
def __repr__(self):
return f"""<{self.__class__.__name__}(id={self.id}, ue_id={
- self.ue_id}, titre='{self.titre}')>"""
+ self.ue_id}, titre='{self.titre!r}')>"""
def to_dict(self):
"""as a dict, with the same conversions as in ScoDoc7"""
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index e243c3983..99dd4a597 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -5,19 +5,31 @@
import datetime
from functools import cached_property
+from flask import flash
import flask_sqlalchemy
+from sqlalchemy.sql import text
from app import db
from app import log
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
+from app.models.but_refcomp import (
+ ApcAnneeParcours,
+ ApcNiveau,
+ ApcParcours,
+ ApcParcoursNiveauCompetence,
+)
+from app.models.groups import GroupDescr, Partition
import app.scodoc.sco_utils as scu
-from app.models.ues import UniteEns
+from app.models.but_refcomp import ApcParcours
+from app.models.but_refcomp import parcours_formsemestre
+from app.models.etudiants import Identite
from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl
-from app.models.etudiants import Identite
+from app.models.ues import UniteEns
+
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_preferences
from app.scodoc.sco_vdi import ApoEtapeVDI
@@ -113,6 +125,14 @@ class FormSemestre(db.Model):
# ne pas utiliser après migrate_scodoc7_dept_archives
scodoc7_id = db.Column(db.Text(), nullable=True)
+ # BUT
+ parcours = db.relationship(
+ "ApcParcours",
+ secondary=parcours_formsemestre,
+ lazy="subquery",
+ backref=db.backref("formsemestres", lazy=True),
+ )
+
def __init__(self, **kwargs):
super(FormSemestre, self).__init__(**kwargs)
if self.modalite is None:
@@ -121,7 +141,7 @@ class FormSemestre(db.Model):
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>"
- def to_dict(self):
+ def to_dict(self, convert_parcours=False):
"dict (compatible ScoDoc7)"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
@@ -140,6 +160,8 @@ class FormSemestre(db.Model):
d["date_fin"] = d["date_fin_iso"] = ""
d["responsables"] = [u.id for u in self.responsables]
d["titre_formation"] = self.titre_formation()
+ if convert_parcours:
+ d["parcours"] = [p.to_dict() for p in self.parcours]
return d
def get_infos_dict(self) -> dict:
@@ -197,6 +219,22 @@ class FormSemestre(db.Model):
sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
return sem_ues.order_by(UniteEns.numero)
+ def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
+ """UE que suit l'étudiant dans ce semestre BUT
+ en fonction du parcours dans lequel il est inscrit.
+
+ Si voulez les UE d'un parcours, il est plus efficace de passer par
+ `formation.query_ues_parcour(parcour)`.
+ """
+ return self.query_ues().filter(
+ FormSemestreInscription.etudid == etudid,
+ FormSemestreInscription.formsemestre == self,
+ UniteEns.niveau_competence_id == ApcNiveau.id,
+ ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
+ ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
+ ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
+ )
+
@cached_property
def modimpls_sorted(self) -> list[ModuleImpl]:
"""Liste des modimpls du semestre (y compris bonus)
@@ -223,6 +261,28 @@ class FormSemestre(db.Model):
)
return modimpls
+ def modimpls_parcours(self, parcours: ApcParcours) -> list[ModuleImpl]:
+ """Liste des modimpls du semestre (sans les bonus (?)) dans le parcours donné.
+ - triée par type/numéro/code ??
+ """
+ cursor = db.session.execute(
+ text(
+ """
+ SELECT modimpl.id
+ FROM notes_moduleimpl modimpl, notes_modules mod,
+ parcours_modules pm, parcours_formsemestre pf
+ WHERE modimpl.formsemestre_id = :formsemestre_id
+ AND modimpl.module_id = mod.id
+ AND pm.module_id = mod.id
+ AND pm.parcours_id = pf.parcours_id
+ AND pf.parcours_id = :parcours_id
+ AND pf.formsemestre_id = :formsemestre_id
+ """
+ ),
+ {"formsemestre_id": self.id, "parcours_id": parcours.id},
+ )
+ return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]
+
def can_be_edited_by(self, user):
"""Vrai si user peut modifier ce semestre"""
if not user.has_permission(Permission.ScoImplement): # pas chef
@@ -289,6 +349,25 @@ class FormSemestre(db.Model):
return ""
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
+ def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
+ """Calcule la liste des regroupements cohérents d'UE impliquant ce
+ formsemestre.
+ Pour une année donnée: l'étudiant est inscrit dans ScoDoc soit dans le semestre
+ impair, soit pair, soit les deux (il est rare mais pas impossible d'avoir une
+ inscription seulement en semestre pair, par exemple suite à un transfert ou un
+ arrêt temporaire du cursus).
+
+ 1. Déterminer l'*autre* formsemestre: semestre précédent ou suivant de la même
+ année, formation compatible (même référentiel de compétence) dans lequel
+ l'étudiant est inscrit.
+
+ 2. Construire les couples d'UE (regroupements cohérents): apparier les UE qui
+ ont le même `ApcParcoursNiveauCompetence`.
+ """
+ if not self.formation.is_apc():
+ return []
+ raise NotImplementedError() # XXX
+
def responsables_str(self, abbrev_prenom=True) -> str:
"""chaîne "J. Dupond, X. Martin"
ou "Jacques Dupond, Xavier Martin"
@@ -305,6 +384,11 @@ class FormSemestre(db.Model):
"True si l'user est l'un des responsables du semestre"
return user.id in [u.id for u in self.responsables]
+ def annee_scolaire(self) -> int:
+ """L'année de début de l'année scolaire.
+ Par exemple, 2022 si le semestre va de septebre 2022 à février 2023."""
+ return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
+
def annee_scolaire_str(self):
"2021 - 2022"
return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month)
@@ -403,6 +487,19 @@ class FormSemestre(db.Model):
etudid, self.date_debut.isoformat(), self.date_fin.isoformat()
)
+ def get_codes_apogee(self, category=None) -> set[str]:
+ """Les codes Apogée (codés en base comme "VRT1,VRT2")
+ category: None: tous, "etapes": étapes associées, "sem: code semestre", "annee": code annuel
+ """
+ codes = set()
+ if category is None or category == "etapes":
+ codes |= {e.etape_apo for e in self.etapes if e}
+ if (category is None or category == "sem") and self.elt_sem_apo:
+ codes |= {x.strip() for x in self.elt_sem_apo.split(",") if x}
+ if (category is None or category == "annee") and self.elt_annee_apo:
+ codes |= {x.strip() for x in self.elt_annee_apo.split(",") if x}
+ return codes
+
def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]:
"""Liste des étudiants inscrits à ce semestre
Si include_demdef, tous les étudiants, avec les démissionnaires
@@ -427,6 +524,85 @@ class FormSemestre(db.Model):
"""Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions}
+ def setup_parcours_groups(self) -> None:
+ """Vérifie et créee si besoin la partition et les groupes de parcours BUT."""
+ if not self.formation.is_apc():
+ return
+ partition = Partition.query.filter_by(
+ formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
+ ).first()
+ if partition is None:
+ # Création de la partition de parcours
+ partition = Partition(
+ formsemestre_id=self.id,
+ partition_name=scu.PARTITION_PARCOURS,
+ numero=-1,
+ )
+ db.session.add(partition)
+ db.session.flush() # pour avoir un id
+ flash(f"Partition Parcours créée.")
+
+ for parcour in self.parcours:
+ if parcour.code:
+ group = GroupDescr.query.filter_by(
+ partition_id=partition.id, group_name=parcour.code
+ ).first()
+ if not group:
+ partition.groups.append(GroupDescr(group_name=parcour.code))
+ db.session.commit()
+
+ def update_inscriptions_parcours_from_groups(self) -> None:
+ """Met à jour les inscriptions dans les parcours du semestres en
+ fonction des groupes de parcours.
+ Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS
+ et leur nom est le code du parcours (eg "Cyber").
+ """
+ partition = Partition.query.filter_by(
+ formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
+ ).first()
+ if partition is None: # pas de partition de parcours
+ return
+
+ # Efface les inscriptions aux parcours:
+ db.session.execute(
+ text(
+ """UPDATE notes_formsemestre_inscription
+ SET parcour_id=NULL
+ WHERE formsemestre_id=:formsemestre_id
+ """
+ ),
+ {
+ "formsemestre_id": self.id,
+ },
+ )
+ # Inscrit les étudiants des groupes de parcours:
+ for group in partition.groups:
+ query = ApcParcours.query.filter_by(code=group.group_name)
+ if query.count() != 1:
+ log(
+ f"""update_inscriptions_parcours_from_groups: {
+ query.count()} parcours with code {group.group_name}"""
+ )
+ continue
+ parcour = query.first()
+ db.session.execute(
+ text(
+ """UPDATE notes_formsemestre_inscription ins
+ SET parcour_id=:parcour_id
+ FROM group_membership gm
+ WHERE formsemestre_id=:formsemestre_id
+ AND gm.etudid = ins.etudid
+ AND gm.group_id = :group_id
+ """
+ ),
+ {
+ "formsemestre_id": self.id,
+ "parcour_id": parcour.id,
+ "group_id": group.id,
+ },
+ )
+ db.session.commit()
+
# Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
notes_formsemestre_responsables = db.Table(
@@ -580,7 +756,9 @@ class FormSemestreInscription(db.Model):
id = db.Column(db.Integer, primary_key=True)
formsemestre_inscription_id = db.synonym("id")
- etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), index=True)
+ etudid = db.Column(
+ db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
+ )
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
@@ -600,11 +778,16 @@ class FormSemestreInscription(db.Model):
)
# I inscrit, D demission en cours de semestre, DEF si "defaillant"
etat = db.Column(db.String(CODE_STR_LEN), index=True)
- # etape apogee d'inscription (experimental 2020)
+ # Etape Apogée d'inscription (ajout 2020)
etape = db.Column(db.String(APO_CODE_STR_LEN))
+ # Parcours (pour les BUT)
+ parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True)
+ parcour = db.relationship(ApcParcours)
def __repr__(self):
- return f"<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={self.formsemestre_id} etat={self.etat}>"
+ return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
+ self.formsemestre_id} etat={self.etat} {
+ ('parcours='+str(self.parcour)) if self.parcour else ''}>"""
class NotesSemSet(db.Model):
diff --git a/app/models/groups.py b/app/models/groups.py
index 4c64ad543..27b763d11 100644
--- a/app/models/groups.py
+++ b/app/models/groups.py
@@ -23,7 +23,7 @@ class Partition(db.Model):
)
# "TD", "TP", ... (NULL for 'all')
partition_name = db.Column(db.String(SHORT_STR_LEN))
- # numero = ordre de presentation)
+ # Numero = ordre de presentation)
numero = db.Column(db.Integer)
# Calculer le rang ?
bul_show_rank = db.Column(
@@ -33,6 +33,10 @@ class Partition(db.Model):
show_in_lists = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true"
)
+ # Editable ? (faux pour les groupes de parcours)
+ groups_editable = db.Column(
+ db.Boolean(), nullable=False, default=True, server_default="true"
+ )
groups = db.relationship(
"GroupDescr",
backref=db.backref("partition", lazy=True),
@@ -106,7 +110,7 @@ class GroupDescr(db.Model):
group_membership = db.Table(
"group_membership",
- db.Column("etudid", db.Integer, db.ForeignKey("identite.id")),
+ db.Column("etudid", db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE")),
db.Column("group_id", db.Integer, db.ForeignKey("group_descr.id")),
db.UniqueConstraint("etudid", "group_id"),
)
@@ -116,5 +120,5 @@ group_membership = db.Table(
# __tablename__ = "group_membership"
# __table_args__ = (db.UniqueConstraint("etudid", "group_id"),)
# id = db.Column(db.Integer, primary_key=True)
-# etudid = db.Column(db.Integer, db.ForeignKey("identite.id"))
+# etudid = db.Column(db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"))
# group_id = db.Column(db.Integer, db.ForeignKey("group_descr.id"))
diff --git a/app/models/modules.py b/app/models/modules.py
index 67ff3de0d..b36557722 100644
--- a/app/models/modules.py
+++ b/app/models/modules.py
@@ -3,6 +3,7 @@
from app import db
from app.models import APO_CODE_STR_LEN
+from app.models.but_refcomp import app_critiques_modules, parcours_modules
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.sco_utils import ModuleType
@@ -44,13 +45,27 @@ class Module(db.Model):
lazy=True,
backref=db.backref("modules", lazy=True),
)
+ # BUT
+ parcours = db.relationship(
+ "ApcParcours",
+ secondary=parcours_modules,
+ lazy="subquery",
+ backref=db.backref("modules", lazy=True),
+ )
+
+ app_critiques = db.relationship(
+ "ApcAppCritique",
+ secondary=app_critiques_modules,
+ lazy="subquery",
+ backref=db.backref("modules", lazy=True),
+ )
def __init__(self, **kwargs):
self.ue_coefs = []
super(Module, self).__init__(**kwargs)
def __repr__(self):
- return f""
+ return f""
def to_dict(self):
e = dict(self.__dict__)
@@ -160,6 +175,12 @@ class Module(db.Model):
# Liste seulement les coefs définis:
return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()]
+ def get_codes_apogee(self) -> set[str]:
+ """Les codes Apogée (codés en base comme "VRT1,VRT2")"""
+ if self.code_apogee:
+ return {x.strip() for x in self.code_apogee.split(",") if x}
+ return set()
+
class ModuleUECoef(db.Model):
"""Coefficients des modules vers les UE (APC, BUT)
diff --git a/app/models/notes.py b/app/models/notes.py
index 6da4ef5d6..f88f87287 100644
--- a/app/models/notes.py
+++ b/app/models/notes.py
@@ -17,7 +17,7 @@ class BulAppreciations(db.Model):
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
)
formsemestre_id = db.Column(
@@ -36,7 +36,7 @@ class NotesNotes(db.Model):
id = db.Column(db.Integer, primary_key=True)
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
)
evaluation_id = db.Column(
db.Integer, db.ForeignKey("notes_evaluation.id"), index=True
@@ -56,7 +56,7 @@ class NotesNotesLog(db.Model):
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
)
evaluation_id = db.Column(
db.Integer,
diff --git a/app/models/ues.py b/app/models/ues.py
index 48d81a14f..450482bc9 100644
--- a/app/models/ues.py
+++ b/app/models/ues.py
@@ -40,8 +40,15 @@ class UniteEns(db.Model):
# coef UE, utilise seulement si l'option use_ue_coefs est activée:
coefficient = db.Column(db.Float)
+ # coef. pour le calcul de moyennes de RCUE. Par défaut, 1.
+ coef_rcue = db.Column(db.Float, nullable=False, default=1.0, server_default="1.0")
+
color = db.Column(db.Text())
+ # BUT
+ niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"))
+ niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
+
# relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
modules = db.relationship("Module", lazy="dynamic", backref="ue")
@@ -113,3 +120,9 @@ class UniteEns(db.Model):
(Module.module_type != scu.ModuleType.SAE),
(Module.module_type != scu.ModuleType.RESSOURCE),
).all()
+
+ def get_codes_apogee(self) -> set[str]:
+ """Les codes Apogée (codés en base comme "VRT1,VRT2")"""
+ if self.code_apogee:
+ return {x.strip() for x in self.code_apogee.split(",") if x}
+ return set()
diff --git a/app/models/validations.py b/app/models/validations.py
index 0bf487f3a..42d7ba0d6 100644
--- a/app/models/validations.py
+++ b/app/models/validations.py
@@ -6,6 +6,7 @@
from app import db
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
+from app.models.events import Scolog
class ScolarFormSemestreValidation(db.Model):
@@ -19,7 +20,7 @@ class ScolarFormSemestreValidation(db.Model):
formsemestre_validation_id = db.synonym("id")
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
)
formsemestre_id = db.Column(
@@ -36,7 +37,7 @@ class ScolarFormSemestreValidation(db.Model):
# NULL pour les UE, True|False pour les semestres:
assidu = db.Column(db.Boolean)
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
- # NULL sauf si compense un semestre:
+ # NULL sauf si compense un semestre: (pas utilisé pour BUT)
compense_formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
@@ -54,7 +55,7 @@ class ScolarFormSemestreValidation(db.Model):
ue = db.relationship("UniteEns", lazy="select", uselist=False)
def __repr__(self):
- return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue_id}, moy_ue={self.moy_ue})"
+ return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
class ScolarAutorisationInscription(db.Model):
@@ -66,10 +67,10 @@ class ScolarAutorisationInscription(db.Model):
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
)
formation_code = db.Column(db.String(SHORT_STR_LEN), nullable=False)
- # semestre ou on peut s'inscrire:
+ # Indice du semestre où on peut s'inscrire:
semestre_id = db.Column(db.Integer)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
origin_formsemestre_id = db.Column(
@@ -77,6 +78,44 @@ class ScolarAutorisationInscription(db.Model):
db.ForeignKey("notes_formsemestre.id"),
)
+ @classmethod
+ def autorise_etud(
+ cls,
+ etudid: int,
+ formation_code: str,
+ origin_formsemestre_id: int,
+ semestre_id: int,
+ ):
+ """Enregistre une autorisation, remplace celle émanant du même semestre si elle existe."""
+ cls.delete_autorisation_etud(etudid, origin_formsemestre_id)
+ autorisation = cls(
+ etudid=etudid,
+ formation_code=formation_code,
+ origin_formsemestre_id=origin_formsemestre_id,
+ semestre_id=semestre_id,
+ )
+ db.session.add(autorisation)
+ Scolog.logdb("autorise_etud", etudid=etudid, msg=f"passage vers S{semestre_id}")
+
+ @classmethod
+ def delete_autorisation_etud(
+ cls,
+ etudid: int,
+ origin_formsemestre_id: int,
+ ):
+ """Efface les autorisations de cette étudiant venant du sem. origine"""
+ autorisations = cls.query.filter_by(
+ etudid=etudid, origin_formsemestre_id=origin_formsemestre_id
+ )
+ for autorisation in autorisations:
+ db.session.delete(autorisation)
+ Scolog.logdb(
+ "autorise_etud",
+ etudid=etudid,
+ msg=f"annule passage vers S{autorisation.semestre_id}",
+ )
+ db.session.flush()
+
class ScolarEvent(db.Model):
"""Evenement dans le parcours scolaire d'un étudiant"""
@@ -86,7 +125,7 @@ class ScolarEvent(db.Model):
event_id = db.synonym("id")
etudid = db.Column(
db.Integer,
- db.ForeignKey("identite.id"),
+ db.ForeignKey("identite.id", ondelete="CASCADE"),
)
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
formsemestre_id = db.Column(
diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py
index c8acefaf5..ae7007683 100644
--- a/app/scodoc/TrivialFormulator.py
+++ b/app/scodoc/TrivialFormulator.py
@@ -207,12 +207,16 @@ class TF(object):
else:
self.values[field] = 1
if field not in self.values:
- if "default" in descr: # first: default in form description
- self.values[field] = descr["default"]
- else: # then: use initvalues dict
- self.values[field] = self.initvalues.get(field, "")
- if self.values[field] == None:
- self.values[field] = ""
+ if (descr.get("input_type", None) == "checkbox") and self.submitted():
+ # aucune case cochée
+ self.values[field] = []
+ else:
+ if "default" in descr: # first: default in form description
+ self.values[field] = descr["default"]
+ else: # then: use initvalues dict
+ self.values[field] = self.initvalues.get(field, "")
+ if self.values[field] is None:
+ self.values[field] = ""
# convert numbers, except ids
if field.endswith("id") and self.values[field]:
@@ -392,9 +396,7 @@ class TF(object):
if self.top_buttons:
R.append(buttons_markup + "")
R.append('
')
- idx = 0
- for idx in range(len(self.formdescription)):
- (field, descr) = self.formdescription[idx]
+ for field, descr in self.formdescription:
if descr.get("readonly", False):
R.append(self._ReadOnlyElement(field, descr))
continue
@@ -408,7 +410,7 @@ class TF(object):
input_type = descr.get("input_type", "text")
item_dom_id = descr.get("dom_id", "")
if item_dom_id:
- item_dom_attr = ' id="%s"' % item_dom_id
+ item_dom_attr = f' id="{item_dom_id}"'
else:
item_dom_attr = ""
# choix du template
@@ -523,7 +525,6 @@ class TF(object):
else:
checked = ""
else: # boolcheckbox
- # open('/tmp/toto','a').write('GenForm: values[%s] = %s (%s)\n' % (field, values[field], type(values[field])))
if values[field] == "True":
v = True
elif values[field] == "False":
diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py
index 1c708ca5c..0fab06eae 100644
--- a/app/scodoc/gen_tables.py
+++ b/app/scodoc/gen_tables.py
@@ -45,7 +45,7 @@ import random
from collections import OrderedDict
from xml.etree import ElementTree
import json
-
+from openpyxl.utils import get_column_letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
from reportlab.lib.colors import Color
@@ -127,6 +127,8 @@ class GenTable(object):
filename="table", # filename, without extension
xls_sheet_name="feuille",
xls_before_table=[], # liste de cellules a placer avant la table
+ xls_style_base=None, # style excel pour les cellules
+ xls_columns_width=None, # { col_id : largeur en "pixels excel" }
pdf_title="", # au dessus du tableau en pdf
pdf_table_style=None,
pdf_col_widths=None,
@@ -151,6 +153,8 @@ class GenTable(object):
self.page_title = page_title
self.pdf_link = pdf_link
self.xls_link = xls_link
+ self.xls_style_base = xls_style_base
+ self.xls_columns_width = xls_columns_width or {}
self.xml_link = xml_link
# HTML parameters:
if not table_id: # random id
@@ -495,7 +499,8 @@ class GenTable(object):
sheet = wb.create_sheet(sheet_name=self.xls_sheet_name)
sheet.rows += self.xls_before_table
style_bold = sco_excel.excel_make_style(bold=True)
- style_base = sco_excel.excel_make_style()
+ style_base = self.xls_style_base or sco_excel.excel_make_style()
+
sheet.append_row(sheet.make_row(self.get_titles_list(), style_bold))
for line in self.get_data_list(xls_mode=True):
sheet.append_row(sheet.make_row(line, style_base))
@@ -505,6 +510,16 @@ class GenTable(object):
if self.origin:
sheet.append_blank_row() # empty line
sheet.append_single_cell_row(self.origin, style_base)
+ # Largeurs des colonnes
+ columns_ids = list(self.columns_ids)
+ for col_id, width in self.xls_columns_width.items():
+ try:
+ idx = columns_ids.index(col_id)
+ col = get_column_letter(idx + 1)
+ sheet.set_column_dimension_width(col, width)
+ except ValueError:
+ pass
+
if wb is None:
return sheet.generate()
diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py
index 2cf0be400..0b2a3d587 100644
--- a/app/scodoc/html_sco_header.py
+++ b/app/scodoc/html_sco_header.py
@@ -59,35 +59,29 @@ BOOTSTRAP_MULTISELECT_CSS = [
def standard_html_header():
"""Standard HTML header for pages outside depts"""
# not used in ZScolar, see sco_header
- return """
+ return f"""
ScoDoc: accueil
-
+
-
+
-%s""" % (
- scu.SCO_ENCODING,
- scu.CUSTOM_HTML_HEADER_CNX,
- )
+{scu.CUSTOM_HTML_HEADER_CNX}"""
def standard_html_footer():
"""Le pied de page HTML de la page d'accueil."""
- return """
+ return f"""
Problème de connexion (identifiant, mot de passe): contacter votre responsable ou chef de département.