ScoDoc/app/but/jury_but.py

588 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

##############################################################################
# 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 dune compétence emporte la validation
de lensemble 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.
"""
from operator import attrgetter
from typing import Union
from app import log
from app.comp.res_but import ResultatsSemestreBUT
from app.comp import res_sem
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
)
from app.models import but_validations
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 import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException, ScoValueError
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.code_valide: str = code_valide
"La décision actuelle enregistrée"
self.explanation: str = explanation
"Explication à afficher à côté de la décision"
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 dune 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 dUE;
- 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.DEF,
sco_codes.DEM,
sco_codes.EXCLU,
]
def __init__(
self,
etud: Identite,
formsemestre: FormSemestre,
):
super().__init__(etud=etud)
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 // 2 + 1
"le rang de l'année dans le BUT: 1, 2, 3"
assert self.annee_but in (1, 2, 3)
self.validation = ApcValidationAnnee.query.filter_by(
etudid=self.etud.id,
formsemestre_id=formsemestre_impair.id,
ordre=self.annee_but,
).first()
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)"
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
}
)
assert self.parcour is not None
self.rcues_annee = self.compute_rcues_annee()
"RCUEs de l'année"
self.niveaux_competences = ApcNiveau.niveaux_annee_de_parcours(
self.parcour, self.annee_but
).all() # XXX à trier, selon l'ordre des UE associées ?
"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.nb_competences = len(self.niveaux_competences)
"le nombre de niveaux de compétences à valider cette année"
self.nb_validables = len(
[rcue for rcue in self.rcues_annee if rcue.est_validable()]
)
"le nombre de comp. validables (éventuellement par compensation)"
self.nb_rcues_under_8 = len(
[rcue for rcue in self.rcues_annee 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)
admis = self.nb_validables == self.nb_competences
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 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.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.ADJ] + self.codes
self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} niveau < 8"
#
def infos(self) -> str:
"informations, for debugging purpose"
return f"""DecisionsProposeesAnnee
etud: {self.etud}
formsemestre_impair: {self.formsemestre_impair}
formsemestre_pair: {self.formsemestre_pair}
RCUEs: {self.rcues_annee}
nb_competences: {self.nb_competences}
nb_nb_validables: {self.nb_validables}
codes: {self.codes}
explanation: {self.explanation}
"""
def comp_formsemestres(
self, formsemestre: FormSemestre
) -> tuple[FormSemestre, FormSemestre]:
"les deux formsemestres de l'année scolaire à laquelle appartient formsemestre"
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 in self.formsemestre_impair, self.formsemestre_pair:
if formsemestre is None:
ues = []
else:
formation: Formation = formsemestre.formation
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
formsemestre
)
# 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 = list(res.etud_ues(etudid))
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)
.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.remove(ue_impair.id)
break
if rcue is None:
raise ScoValueError(f"pas de RCUE pour l'UE {ue_pair.acronyme}")
rcues_annee.append(rcue)
if len(ues_impair_sans_rcue) > 0:
ue = UniteEns.query.get(ues_impair_sans_rcue.pop())
raise ScoValueError(f"pas de RCUE pour l'UE {ue.acronyme}")
return rcues_annee
def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
"""Pour chaque niveau de compétence de cette année, donne 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
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
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.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
validation = rcue.query_validations().first()
if validation is not None:
self.code_valide = 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)
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.DEM,
sco_codes.UEBSL,
]
def __init__(
self,
etud: Identite,
formsemestre: FormSemestre,
ue: UniteEns,
):
super().__init__(etud=etud)
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"
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 dune 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 donéne par l'arrêté BUT:
* La validation des deux UE du niveau dune compétence emporte la validation de
l'ensemble des UE du niveau inférieur de cette même compétence.
"""
pass