WIP: jurys BUT: décisions possibles sur année

This commit is contained in:
Emmanuel Viennet 2022-06-20 17:56:27 +02:00
parent a3cccac2d2
commit a709e9d6e9
8 changed files with 657 additions and 161 deletions

View File

@ -5,163 +5,463 @@
############################################################################## ##############################################################################
"""Jury BUT: logique de gestion """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 operator import attrgetter
from typing import Union
from app import log
from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.comp import res_sem from app.comp import res_sem
from app.models import but_validations
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours, ApcAnneeParcours,
ApcCompetence, ApcCompetence,
ApcNiveau, ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence, ApcParcoursNiveauCompetence,
) )
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models import but_validations
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
RegroupementCoherentUE,
)
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.formations import Formation from app.models.formations import Formation
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.scodoc import sco_codes_parcours as codes 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 import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_exceptions import ScoException, ScoValueError
class RegroupementCoherentUE:
def __init__(
self,
etud: Identite,
formsemestre_1: FormSemestre,
ue_1: UniteEns,
formsemestre_2: FormSemestre,
ue_2: UniteEns,
):
self.formsemestre_1 = formsemestre_1
self.ue_1 = ue_1
self.formsemestre_2 = formsemestre_2
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]
else:
self.moy_ue_1 = None
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_1.id][etud.id]
else:
self.moy_ue_2 = None
# Calcul de la moyenne au RCUE
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
# Moyenne RCUE non pondérée (pour le moment)
self.moy_rcue = (self.moy_ue_1 + self.moy_ue_2) / 2
else:
self.moy_rcue = None
class DecisionsProposees: class DecisionsProposees:
# Codes toujours proposés sauf si include_communs est faux: """Une décision de jury proposé, constituée d'une liste de codes et d'une explication.
codes_communs = [codes.RAT, codes.DEF, codes.ABAN, codes.DEM, codes.UEBSL] Super-classe, spécialisée pour les UE, les RCUE, les années et le diplôme.
def __init__(self, code: str = None, explanation="", include_communs=True): 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: if include_communs:
self.codes = self.codes_communs self.codes = self.codes_communs
else:
self.codes = []
if isinstance(code, list): if isinstance(code, list):
self.codes = code + self.codes_communs self.codes = code + self.codes_communs
elif code is not None: elif code is not None:
self.codes = [code] + self.codes_communs self.codes = [code] + self.codes_communs
self.explanation = explanation self.code_valide: str = code_valide
"La décision actuelle enregistrée"
self.explanation: str = explanation
"Explication en à afficher à côté de la décision"
def __repr__(self) -> str: def __repr__(self) -> str:
return f"""<{self.__class__.__name__} codes={self.codes} explanation={self.explanation}""" return f"""<{self.__class__.__name__} valid={self.code_valide
} codes={self.codes} explanation={self.explanation}"""
def decisions_ue_proposees( class DecisionsProposeesAnnee(DecisionsProposees):
etud: Identite, formsemestre: FormSemestre, ue: UniteEns """Décisions de jury sur une année (ETP) du BUT
) -> DecisionsProposees:
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
assert self.parcour is not None
self.rcues_annee = self.compute_rcues_annee()
"RCUEs de l'année"
self.nb_competences = len(
ApcNiveau.niveaux_annee_de_parcours(self.parcour, self.annee_but).all()
) # note that .count() won't give the same res
self.nb_validables = len(
[rcue for rcue in self.rcues_annee if rcue.est_validable()]
)
self.nb_rcues_under_8 = len(
[rcue for rcue in self.rcues_annee if not rcue.est_suffisant()]
)
# année ADM si toutes RCUE validées (sinon PASD)
admis = self.nb_validables == self.nb_competences
valide_moitie_rcue = self.nb_validables > self.nb_competences // 2
# Peut passer si plus de la moitié validables et tous > 8
passage_de_droit = valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
# XXX TODO ajouter condition pour passage en S5
# Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR
expl_rcues = f"{self.nb_validables} validables sur {self.nb_competences}"
if admis:
self.codes = [sco_codes.ADM] + self.codes
self.explanation = expl_rcues
elif passage_de_droit:
self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes
self.explanation = expl_rcues
elif 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} < 8"
#
def infos(self) -> str:
"informations, for debugging purpose"
return f"""DecisionsProposeesAnnee
etud: {self.etud}
formsemestre_pair: {self.formsemestre_pair}
formsemestre_impair: {self.formsemestre_impair}
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 = 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 = ues_impair_sans_rcue.pop()
raise ScoValueError(f"pas de RCUE pour l'UE {ue.acronyme}")
return rcues_annee
class DecisionsProposeesRCUE(DecisionsProposees):
"""Liste des codes de décisions que l'on peut proposer pour """Liste des codes de décisions que l'on peut proposer pour
cette UE de cet étudiant dans ce semestre. le RCUE de cet étudiant dans cette année.
si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL 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_compense():
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 si moy_ue > 10, ADM
sinon si compensation dans RCUE: CMP sinon si compensation dans RCUE: CMP
sinon: ADJ, AJ sinon: ADJ, AJ
et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL
et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL (codes_communs)
""" """
if ue.type == codes.UE_SPORT:
return DecisionsProposees( # Codes toujours proposés sauf si include_communs est faux:
explanation="UE bonus, pas de décision de jury", include_communs=False 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
# 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
# Code sur année ? # Code sur année ?
decision_annee = ApcValidationAnnee.query.filter_by( decision_annee = ApcValidationAnnee.query.filter_by(
etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire() etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire()
).first() ).first()
if ( if (
decision_annee is not None and decision_annee.code in codes.CODES_ANNEE_ARRET decision_annee is not None
and decision_annee.code in sco_codes.CODES_ANNEE_ARRET
): # DEF, DEM, ABAN, ABL ): # DEF, DEM, ABAN, ABL
return DecisionsProposees( self.explanation = f"l'année a le code {decision_annee.code}"
code=decision_annee.code, self.codes = [decision_annee.code] # sans les codes communs
explanation=f"l'année a le code {decision_annee.code}", return
include_communs=False,
)
# Moyenne de l'UE ? # Moyenne de l'UE ?
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
if not ue.id in res.etud_moy_ue: if not ue.id in res.etud_moy_ue:
return DecisionsProposees(explanation="UE sans résultat") self.explanation = "UE sans résultat"
return
if not etud.id in res.etud_moy_ue[ue.id]: if not etud.id in res.etud_moy_ue[ue.id]:
return DecisionsProposees(explanation="Étudiant sans résultat dans cette UE") self.explanation = "Étudiant sans résultat dans cette UE"
return
moy_ue = res.etud_moy_ue[ue.id][etud.id] moy_ue = res.etud_moy_ue[ue.id][etud.id]
if moy_ue > (codes.ParcoursBUT.BARRE_MOY - codes.NOTES_TOLERANCE): if moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE):
return DecisionsProposees( self.codes.insert(0, sco_codes.ADM)
code=codes.ADM, self.explanation = (f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20",)
explanation=f"Moyenne >= {codes.ParcoursBUT.BARRE_MOY}/20",
) # Compensation dans un RCUE ?
# Compensation dans le RCUE ? rcues = but_validations.find_rcues(formsemestre, ue, etud)
other_ue, other_formsemestre = but_validations.get_other_ue_rcue(ue, etud.id) for rcue in rcues:
if other_ue is not None: if rcue.est_validable():
# inscrit à une autre UE du même RCUE self.codes.insert(0, sco_codes.CMP)
other_res: ResultatsSemestreBUT = res_sem.load_formsemestre_results( self.explanation = f"Compensée par {rcue.other_ue(ue)} (moyenne RCUE={scu.fmt_note(rcue.moy_rcue)}/20"
other_formsemestre self.rcue = rcue
) return # s'arrête au 1er RCU validable
if (other_ue.id in other_res.etud_moy_ue) and (
etud.id in other_res.etud_moy_ue[other_ue.id] # Échec à valider cette UE
): self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
other_moy_ue = other_res.etud_moy_ue[other_ue.id][etud.id] self.explanation = "notes insuffisantes"
# Moyenne RCUE: non pondérée (pour le moment)
moy_rcue = (moy_ue + other_moy_ue) / 2
if moy_rcue > codes.NOTES_BARRE_GEN_COMPENSATION: # 10-epsilon
return DecisionsProposees(
code=codes.CMP,
explanation=f"Compensée par {other_ue} (moyenne RCUE={scu.fmt_note(moy_rcue)}/20",
)
return DecisionsProposees(
code=[codes.AJ, codes.ADJ],
explanation="notes insuffisantes",
)
def decisions_rcue_proposees( class BUTCursusEtud: # WIP TODO
etud: Identite,
formsemestre_1: FormSemestre,
ue_1: UniteEns,
formsemestre_2: FormSemestre,
ue_2: UniteEns,
) -> DecisionsProposees:
"""Liste des codes de décisions que l'on peut proposer pour
le RCUE de cet étudiant dans ces semestres.
ADM, CMP, ADJ, AJ, RAT, DEF, ABAN
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.
"""
#
class BUTCursusEtud:
"""Validation du cursus d'un étudiant""" """Validation du cursus d'un étudiant"""
def __init__(self, formsemestre: FormSemestre, etud: Identite): def __init__(self, formsemestre: FormSemestre, etud: Identite):
@ -215,6 +515,7 @@ class BUTCursusEtud:
"""Vrai si la compétence est validée, c'est à dire que tous ses """Vrai si la compétence est validée, c'est à dire que tous ses
niveaux sont validés (ApcValidationRCUE). niveaux sont validés (ApcValidationRCUE).
""" """
# XXX A REVOIR
validations = ( validations = (
ApcValidationRCUE.query.filter_by(etudid=self.etud.id) ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
.join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id) .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)

View File

@ -29,6 +29,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
"modimpl_coefs_df", "modimpl_coefs_df",
"modimpls_evals_poids", "modimpls_evals_poids",
"sem_cube", "sem_cube",
"etuds_parcour_id", # parcours de chaque étudiant
"ues_inscr_parcours_df", # inscriptions aux UE / parcours "ues_inscr_parcours_df", # inscriptions aux UE / parcours
) )
@ -37,7 +38,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
self.sem_cube = None self.sem_cube = None
"""ndarray (etuds x modimpl x ue)""" """ndarray (etuds x modimpl x ue)"""
self.etuds_parcour_id = None
"""Parcours de chaque étudiant { etudid : parcour_id }"""
if not self.load_cached(): if not self.load_cached():
t0 = time.time() t0 = time.time()
self.compute() self.compute()
@ -190,13 +192,14 @@ class ResultatsSemestreBUT(NotesTableCompat):
La matrice avec ue ne comprend que les UE non bonus. La matrice avec ue ne comprend que les UE non bonus.
1.0 si étudiant inscrit à l'UE, NaN sinon. 1.0 si étudiant inscrit à l'UE, NaN sinon.
""" """
etuds_parcours = { etuds_parcour_id = {
inscr.etudid: inscr.parcour_id for inscr in self.formsemestre.inscriptions 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] ue_ids = [ue.id for ue in self.ues]
# matrice de 1, inscrits par défaut à toutes les UE: # matrice de 1, inscrits par défaut à toutes les UE:
ues_inscr_parcours_df = pd.DataFrame( ues_inscr_parcours_df = pd.DataFrame(
1.0, index=etuds_parcours.keys(), columns=ue_ids, dtype=float 1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
) )
if self.formsemestre.formation.referentiel_competence is None: if self.formsemestre.formation.referentiel_competence is None:
return ues_inscr_parcours_df return ues_inscr_parcours_df
@ -209,11 +212,11 @@ class ResultatsSemestreBUT(NotesTableCompat):
parcour parcour
).filter_by(semestre_idx=self.formsemestre.semestre_id) ).filter_by(semestre_idx=self.formsemestre.semestre_id)
} }
for etudid in etuds_parcours: for etudid in etuds_parcour_id:
parcour = etuds_parcours[etudid] parcour = etuds_parcour_id[etudid]
if parcour is not None: if parcour is not None:
ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[ ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[
etuds_parcours[etudid] etuds_parcour_id[etudid]
] ]
return ues_inscr_parcours_df return ues_inscr_parcours_df

View File

@ -3,13 +3,18 @@
"""Décisions de jury (validations) des RCUE et années du BUT """Décisions de jury (validations) des RCUE et années du BUT
""" """
import flask_sqlalchemy
from sqlalchemy.sql import text from sqlalchemy.sql import text
from typing import Union
from app import db from app import db
from app.models import CODE_STR_LEN from app.models import CODE_STR_LEN
from app.models.but_refcomp import ApcNiveau from app.models.but_refcomp import ApcNiveau
from app.models.etudiants import Identite
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.scodoc import sco_codes_parcours as sco_codes
class ApcValidationRCUE(db.Model): class ApcValidationRCUE(db.Model):
@ -17,6 +22,7 @@ class ApcValidationRCUE(db.Model):
aka "regroupements cohérents d'UE" dans le jargon BUT. 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" __tablename__ = "apc_validation_rcue"
@ -58,16 +64,151 @@ class ApcValidationRCUE(db.Model):
return self.ue2.niveau_competence return self.ue2.niveau_competence
def get_other_ue_rcue(ue: UniteEns, etudid: int) -> tuple[UniteEns, FormSemestre]: # Attention: ce n'est pas un modèle mais une classe ordinaire:
"""L'autre UE du RCUE (niveau de compétence) pour cet étudiant. 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.
Cherche une UE du même niveau de compétence, à laquelle l'étudiant soit inscrit. La moyenne (10/20) au RCU déclenche la compensation des UE.
Résultat: le couple (UE, FormSemestre), ou (None, None) si pas trouvée. """
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 non pondérée (pour le moment -- TODO)
self.moy_rcue = (self.moy_ue_1 + self.moy_ue_2) / 2
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_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_compense(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.ADM, sco_codes.ADJ, sco_codes.CMP}
):
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): if (ue.niveau_competence is None) or (ue.semestre_idx is None):
return None, None return []
if ue.semestre_idx % 2: if ue.semestre_idx % 2: # S1, S3, S5
other_semestre_idx = ue.semestre_idx + 1 other_semestre_idx = ue.semestre_idx + 1
else: else:
other_semestre_idx = ue.semestre_idx - 1 other_semestre_idx = ue.semestre_idx - 1
@ -75,45 +216,38 @@ def get_other_ue_rcue(ue: UniteEns, etudid: int) -> tuple[UniteEns, FormSemestre
cursor = db.session.execute( cursor = db.session.execute(
text( text(
"""SELECT """SELECT
ue.id, sem.id ue.id, formsemestre.id
FROM FROM
notes_ue ue, notes_ue ue,
notes_formsemestre_inscription inscr, notes_formsemestre_inscription inscr,
notes_formsemestre sem notes_formsemestre formsemestre
WHERE WHERE
inscr.etudid = :etudid inscr.etudid = :etudid
AND inscr.formsemestre_id = sem.id AND inscr.formsemestre_id = formsemestre.id
AND sem.semestre_id = :other_semestre_idx AND formsemestre.semestre_id = :other_semestre_idx
AND ue.formation_id = sem.formation_id AND ue.formation_id = formsemestre.formation_id
AND ue.niveau_competence_id = :ue_niveau_competence_id AND ue.niveau_competence_id = :ue_niveau_competence_id
AND ue.semestre_idx = :other_semestre_idx AND ue.semestre_idx = :other_semestre_idx
""" """
), ),
{ {
"etudid": etudid, "etudid": etud.id,
"other_semestre_idx": other_semestre_idx, "other_semestre_idx": other_semestre_idx,
"ue_niveau_competence_id": ue.niveau_competence_id, "ue_niveau_competence_id": ue.niveau_competence_id,
}, },
) )
r = cursor.fetchone() rcues = []
if r is None: for ue_id, formsemestre_id in cursor:
return None, None other_ue = UniteEns.query.get(ue_id)
other_formsemestre = FormSemestre.query.get(formsemestre_id)
return UniteEns.query.get(r[0]), FormSemestre.query.get(r[1]) rcues.append(
RegroupementCoherentUE(etud, formsemestre, ue, other_formsemestre, other_ue)
# q = UniteEns.query.filter( )
# FormSemestreInscription.etudid == etudid, # safety check: 1 seul niveau de comp. concerné:
# FormSemestreInscription.formsemestre_id == FormSemestre.id, assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
# FormSemestre.formation_id == UniteEns.formation_id, return rcues
# FormSemestre.semestre_id == UniteEns.semestre_idx,
# UniteEns.niveau_competence_id == ue.niveau_competence_id,
# UniteEns.semestre_idx != ue.semestre_idx,
# )
# if q.count() > 1:
# log("Warning: get_other_ue_rcue: {q.count()} candidates UE")
# return q.first()
class ApcValidationAnnee(db.Model): class ApcValidationAnnee(db.Model):
@ -134,6 +268,7 @@ class ApcValidationAnnee(db.Model):
formsemestre_id = db.Column( formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True 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 annee_scolaire = db.Column(db.Integer, nullable=False) # 2021
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True) code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)

View File

@ -54,7 +54,7 @@ class ScolarFormSemestreValidation(db.Model):
ue = db.relationship("UniteEns", lazy="select", uselist=False) ue = db.relationship("UniteEns", lazy="select", uselist=False)
def __repr__(self): 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): class ScolarAutorisationInscription(db.Model):

View File

@ -68,7 +68,8 @@ NOTES_TOLERANCE = 0.00499999999999 # si note >= (BARRE-TOLERANCE), considere ok
# (permet d'eviter d'afficher 10.00 sous barre alors que la moyenne vaut 9.999) # (permet d'eviter d'afficher 10.00 sous barre alors que la moyenne vaut 9.999)
# Barre sur moyenne générale utilisée pour compensations semestres: # Barre sur moyenne générale utilisée pour compensations semestres:
NOTES_BARRE_GEN_COMPENSATION = 10.0 - NOTES_TOLERANCE NOTES_BARRE_GEN = 10.0
NOTES_BARRE_GEN_COMPENSATION = NOTES_BARRE_GEN - NOTES_TOLERANCE
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# Types d'UE: # Types d'UE:
@ -192,6 +193,8 @@ CODES_UE_VALIDES = {ADM: True, CMP: True} # UE validée
# Pour le BUT: # Pour le BUT:
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL} CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
CODES_RCUE = {ADM, AJ, CMP} CODES_RCUE = {ADM, AJ, CMP}
BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE
def code_semestre_validant(code: str) -> bool: def code_semestre_validant(code: str) -> bool:

View File

@ -927,7 +927,7 @@ def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx):
def _ue_table_ues( def _ue_table_ues(
parcours, parcours,
ues, ues: list[dict],
editable, editable,
tag_editable, tag_editable,
has_perm_change, has_perm_change,
@ -936,7 +936,7 @@ def _ue_table_ues(
arrow_none, arrow_none,
delete_icon, delete_icon,
delete_disabled_icon, delete_disabled_icon,
): ) -> str:
"""Édition de programme: liste des UEs (avec leurs matières et modules). """Édition de programme: liste des UEs (avec leurs matières et modules).
Pour les formations classiques (non APC/BUT) Pour les formations classiques (non APC/BUT)
""" """
@ -964,9 +964,9 @@ def _ue_table_ues(
if ue["semestre_id"] == sco_codes_parcours.UE_SEM_DEFAULT: if ue["semestre_id"] == sco_codes_parcours.UE_SEM_DEFAULT:
lab = "Pas d'indication de semestre:" lab = "Pas d'indication de semestre:"
else: else:
lab = "Semestre %s:" % ue["semestre_id"] lab = f"""Semestre {ue["semestre_id"]}:"""
H.append( H.append(
'<div class="ue_list_div"><div class="ue_list_tit_sem">%s</div>' % lab f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>'
) )
H.append('<ul class="notes_ue_list">') H.append('<ul class="notes_ue_list">')
H.append('<li class="notes_ue_list">') H.append('<li class="notes_ue_list">')

View File

@ -32,6 +32,8 @@
ue.color if ue.color is not none else 'blue'}}"></span> ue.color if ue.color is not none else 'blue'}}"></span>
<b>{{ue.acronyme}}</b> <a class="discretelink" href="{{ <b>{{ue.acronyme}}</b> <a class="discretelink" href="{{
url_for('notes.ue_infos', scodoc_dept=g.scodoc_dept, ue_id=ue.id)}}" url_for('notes.ue_infos', scodoc_dept=g.scodoc_dept, ue_id=ue.id)}}"
title="{{ue.acronyme}}: {{'pas de compétence associée' if ue.niveau_competence is none
else 'compétence ' + ue.niveau_competence.annee + ' ' + ue.niveau_competence.competence.titre_long}}"
>{{ue.titre}}</a> >{{ue.titre}}</a>
{% set virg = joiner(", ") %} {% set virg = joiner(", ") %}
<span class="ue_code">( <span class="ue_code">(

View File

@ -31,6 +31,7 @@ Module notes: issu de ScoDoc7 / ZNotes.py
Emmanuel Viennet, 2021 Emmanuel Viennet, 2021
""" """
import html
from operator import itemgetter from operator import itemgetter
import time import time
from xml.etree import ElementTree from xml.etree import ElementTree
@ -41,8 +42,11 @@ from flask import current_app, g, request
from flask_login import current_user from flask_login import current_user
from werkzeug.utils import redirect from werkzeug.utils import redirect
from app.but import jury_but
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.models.formsemestre import FormSemestreUEComputationExpr from app.models.formsemestre import FormSemestreUEComputationExpr
from app.models.modules import Module from app.models.modules import Module
@ -2209,6 +2213,54 @@ def formsemestre_validation_etud_manu(
) )
# --- Jurys BUT
@bp.route(
"/formsemestre_validation_but/<int:formsemestre_id>/<int:etudid>",
methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_validation_but(formsemestre_id: int, etudid: int):
"Form. saisie décision jury semestre BUT"
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
),
)
# XXX TODO Page expérimentale pour les devs
H = [
html_sco_header.sco_header(
page_title="Validation BUT", formsemestre_id=formsemestre_id, etudid=etudid
),
f"""
<h2>XXX Experimental XXX</h2>
""",
]
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
etud = Identite.query.get_or_404(etudid)
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
# ---- UEs
H.append(f"<ul>")
for ue in formsemestre.query_ues(): # volontairement toutes les UE
dec_proposee = jury_but.DecisionsProposeesUE(etud, formsemestre, ue)
H.append("<li>" + html.escape(f"""{ue} : {dec_proposee}""") + "</li>")
H.append(f"</ul>")
if formsemestre.semestre_id % 2 == 0:
# ---- RCUES
H.append(f"<ul>")
for ue in formsemestre.query_ues(): # volontairement toutes les UE
dec_proposee = jury_but.decisions_ue_proposees(etud, formsemestre, ue)
H.append("<li>" + html.escape(f"""{ue} : {dec_proposee}""") + "</li>")
H.append(f"</ul>")
return "\n".join(H) + html_sco_header.sco_footer()
@bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"]) @bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"])
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)