WIP: jurys BUT

This commit is contained in:
Emmanuel Viennet 2022-06-09 07:39:58 +02:00
parent 109e00b6eb
commit 483de3ed0b
10 changed files with 359 additions and 35 deletions

245
app/but/jury_but.py Normal file
View File

@ -0,0 +1,245 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT: logique de gestion
"""
from operator import attrgetter
from app.comp.res_but import ResultatsSemestreBUT
from app.comp import res_sem
from app.models import but_validations
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcoursNiveauCompetence,
)
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc import sco_codes_parcours as codes
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException
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:
# Codes toujours proposés sauf si include_communs est faux:
codes_communs = [codes.RAT, codes.DEF, codes.ABAN, codes.DEM, codes.UEBSL]
def __init__(self, code: str = None, explanation="", include_communs=True):
if include_communs:
self.codes = self.codes_communs
else:
self.codes = []
if isinstance(code, list):
self.codes = code + self.codes_communs
elif code is not None:
self.codes = [code] + self.codes_communs
self.explanation = explanation
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} codes={self.codes} explanation={self.explanation}"""
def decisions_ue_proposees(
etud: Identite, formsemestre: FormSemestre, ue: UniteEns
) -> DecisionsProposees:
"""Liste des codes de décisions que l'on peut proposer pour
cette UE de cet étudiant dans ce 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
"""
if ue.type == codes.UE_SPORT:
return DecisionsProposees(
explanation="UE bonus, pas de décision de jury", include_communs=False
)
# Code sur année ?
decision_annee = ApcValidationAnnee.query.filter_by(
etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire()
).first()
if (
decision_annee is not None and decision_annee.code in codes.CODES_ANNEE_ARRET
): # DEF, DEM, ABAN, ABL
return DecisionsProposees(
code=decision_annee.code,
explanation=f"l'année a le code {decision_annee.code}",
include_communs=False,
)
# Moyenne de l'UE ?
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
if not ue.id in res.etud_moy_ue:
return DecisionsProposees(explanation="UE sans résultat")
if not etud.id in res.etud_moy_ue[ue.id]:
return DecisionsProposees(explanation="Étudiant sans résultat dans cette UE")
moy_ue = res.etud_moy_ue[ue.id][etud.id]
if moy_ue > (codes.ParcoursBUT.BARRE_MOY - codes.NOTES_TOLERANCE):
return DecisionsProposees(
code=codes.ADM,
explanation=f"Moyenne >= {codes.ParcoursBUT.BARRE_MOY}/20",
)
# Compensation dans le RCUE ?
other_ue, other_formsemestre = but_validations.get_other_ue_rcue(ue, etud.id)
if other_ue is not None:
# inscrit à une autre UE du même RCUE
other_res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
other_formsemestre
)
if (other_ue.id in other_res.etud_moy_ue) and (
etud.id in other_res.etud_moy_ue[other_ue.id]
):
other_moy_ue = other_res.etud_moy_ue[other_ue.id][etud.id]
# 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(
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"""
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).
"""
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

View File

@ -219,7 +219,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
def etud_ues_ids(self, etudid: int) -> list[int]: def etud_ues_ids(self, etudid: int) -> list[int]:
"""Liste des id d'UE auxquelles l'étudiant est inscrit (sans bonus). """Liste des id d'UE auxquelles l'étudiant est inscrit (sans bonus).
(surchargée en BUT pour prendre en compte les parcours) (surchargée ici pour prendre en compte les parcours)
""" """
s = self.ues_inscr_parcours_df.loc[etudid] s = self.ues_inscr_parcours_df.loc[etudid]
return s.index[s.notna()] return s.index[s.notna()]

View File

@ -1,14 +1,15 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
"""Décisions de jury validations) des RCUE et années du BUT """Décisions de jury (validations) des RCUE et années du BUT
""" """
from app import db from sqlalchemy.sql import text
from app import log
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.ues import UniteEns from app.models.ues import UniteEns
from app.models.formsemestre import FormSemestre, FormSemestreInscription from app.models.formsemestre import FormSemestre
class ApcValidationRCUE(db.Model): class ApcValidationRCUE(db.Model):
@ -51,24 +52,68 @@ class ApcValidationRCUE(db.Model):
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.etud} {self.ue1}/{self.ue2}:{self.code!r}>" 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 get_other_ue_rcue(ue: UniteEns, etudid: int) -> UniteEns:
"""L'autre UE du RCUE (niveau de compétence) pour cet étudiant, def get_other_ue_rcue(ue: UniteEns, etudid: int) -> tuple[UniteEns, FormSemestre]:
None si pas trouvée. """L'autre UE du RCUE (niveau de compétence) pour cet étudiant.
Cherche une UE du même niveau de compétence, à laquelle l'étudiant soit inscrit.
Résultat: le couple (UE, FormSemestre), ou (None, None) si pas trouvée.
""" """
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 return None, None
q = UniteEns.query.filter(
FormSemestreInscription.etudid == etudid, if ue.semestre_idx % 2:
FormSemestreInscription.formsemestre_id == FormSemestre.id, other_semestre_idx = ue.semestre_idx + 1
FormSemestre.formation_id == UniteEns.formation_id, else:
FormSemestre.semestre_id == UniteEns.semestre_idx, other_semestre_idx = ue.semestre_idx - 1
UniteEns.niveau_competence_id == ue.niveau_competence_id,
UniteEns.semestre_idx != ue.semestre_idx, cursor = db.session.execute(
text(
"""SELECT
ue.id, sem.id
FROM
notes_ue ue,
notes_formsemestre_inscription inscr,
notes_formsemestre sem
WHERE
inscr.etudid = :etudid
AND inscr.formsemestre_id = sem.id
AND sem.semestre_id = :other_semestre_idx
AND ue.formation_id = sem.formation_id
AND ue.niveau_competence_id = :ue_niveau_competence_id
AND ue.semestre_idx = :other_semestre_idx
"""
),
{
"etudid": etudid,
"other_semestre_idx": other_semestre_idx,
"ue_niveau_competence_id": ue.niveau_competence_id,
},
) )
if q.count() > 1: r = cursor.fetchone()
log("Warning: get_other_ue_rcue: {q.count()} candidates UE") if r is None:
return q.first() return None, None
return UniteEns.query.get(r[0]), FormSemestre.query.get(r[1])
# q = UniteEns.query.filter(
# FormSemestreInscription.etudid == etudid,
# FormSemestreInscription.formsemestre_id == FormSemestre.id,
# FormSemestre.formation_id == UniteEns.formation_id,
# 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):

View File

@ -8,6 +8,7 @@ from app.comp import df_cache
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours, ApcAnneeParcours,
ApcCompetence,
ApcNiveau, ApcNiveau,
ApcParcours, ApcParcours,
ApcParcoursNiveauCompetence, ApcParcoursNiveauCompetence,
@ -170,6 +171,27 @@ class Formation(db.Model):
ApcAnneeParcours.parcours_id == parcour.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): class Matiere(db.Model):
"""Matières: regroupe les modules d'une UE """Matières: regroupe les modules d'une UE

View File

@ -221,7 +221,7 @@ class FormSemestre(db.Model):
"""UE que suit l'étudiant dans ce semestre BUT """UE que suit l'étudiant dans ce semestre BUT
en fonction du parcours dans lequel il est inscrit. en fonction du parcours dans lequel il est inscrit.
Si voulez les UE d'un parcour, il est plus efficace de passer par Si voulez les UE d'un parcours, il est plus efficace de passer par
`formation.query_ues_parcour(parcour)`. `formation.query_ues_parcour(parcour)`.
""" """
return self.query_ues().filter( return self.query_ues().filter(
@ -382,6 +382,11 @@ class FormSemestre(db.Model):
"True si l'user est l'un des responsables du semestre" "True si l'user est l'un des responsables du semestre"
return user.id in [u.id for u in self.responsables] 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): def annee_scolaire_str(self):
"2021 - 2022" "2021 - 2022"
return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month) return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month)

View File

@ -138,9 +138,9 @@ def sco_header(
# optional args # optional args
page_title="", # page title page_title="", # page title
no_side_bar=False, # hide sidebar no_side_bar=False, # hide sidebar
cssstyles=[], # additionals CSS sheets cssstyles=(), # additionals CSS sheets
javascripts=[], # additionals JS filenames to load javascripts=(), # additionals JS filenames to load
scripts=[], # script to put in page header scripts=(), # script to put in page header
bodyOnLoad="", # JS bodyOnLoad="", # JS
init_qtip=False, # include qTip init_qtip=False, # include qTip
init_google_maps=False, # Google maps init_google_maps=False, # Google maps
@ -148,6 +148,8 @@ def sco_header(
titrebandeau="", # titre dans bandeau superieur titrebandeau="", # titre dans bandeau superieur
head_message="", # message action (petit cadre jaune en haut) head_message="", # message action (petit cadre jaune en haut)
user_check=True, # verifie passwords temporaires user_check=True, # verifie passwords temporaires
etudid=None,
formsemestre_id=None,
): ):
"Main HTML page header for ScoDoc" "Main HTML page header for ScoDoc"
from app.scodoc.sco_formsemestre_status import formsemestre_page_title from app.scodoc.sco_formsemestre_status import formsemestre_page_title
@ -281,14 +283,14 @@ def sco_header(
H.append(scu.CUSTOM_HTML_HEADER) H.append(scu.CUSTOM_HTML_HEADER)
# #
if not no_side_bar: if not no_side_bar:
H.append(html_sidebar.sidebar()) H.append(html_sidebar.sidebar(etudid))
H.append("""<div id="gtrcontent">""") H.append("""<div id="gtrcontent">""")
# En attendant le replacement complet de cette fonction, # En attendant le replacement complet de cette fonction,
# inclusion ici des messages flask # inclusion ici des messages flask
H.append(render_template("flashed_messages.html")) H.append(render_template("flashed_messages.html"))
# #
# Barre menu semestre: # Barre menu semestre:
H.append(formsemestre_page_title()) H.append(formsemestre_page_title(formsemestre_id))
# Avertissement si mot de passe à changer # Avertissement si mot de passe à changer
if user_check: if user_check:

View File

@ -73,7 +73,7 @@ def sidebar_common():
return "".join(H) return "".join(H)
def sidebar(): def sidebar(etudid: int = None):
"Main HTML page sidebar" "Main HTML page sidebar"
# rewritten from legacy DTML code # rewritten from legacy DTML code
from app.scodoc import sco_abs from app.scodoc import sco_abs
@ -93,14 +93,14 @@ def sidebar():
""" """
] ]
# ---- Il y-a-t-il un etudiant selectionné ? # ---- Il y-a-t-il un etudiant selectionné ?
etudid = g.get("etudid", None) etudid = etudid if etudid is not None else g.get("etudid", None)
if not etudid: if etudid is None:
if request.method == "GET": if request.method == "GET":
etudid = request.args.get("etudid", None) etudid = request.args.get("etudid", None)
elif request.method == "POST": elif request.method == "POST":
etudid = request.form.get("etudid", None) etudid = request.form.get("etudid", None)
if etudid: if etudid is not None:
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
params.update(etud) params.update(etud)
params["fiche_url"] = url_for( params["fiche_url"] = url_for(

View File

@ -190,6 +190,7 @@ CODES_SEM_REO = {NAR: 1} # reorientation
CODES_UE_VALIDES = {ADM: True, CMP: True} # UE validée CODES_UE_VALIDES = {ADM: True, CMP: True} # UE validée
# Pour le BUT: # Pour le BUT:
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
CODES_RCUE = {ADM, AJ, CMP} CODES_RCUE = {ADM, AJ, CMP}

View File

@ -503,20 +503,24 @@ def retreive_formsemestre_from_request() -> int:
# Element HTML decrivant un semestre (barre de menu et infos) # Element HTML decrivant un semestre (barre de menu et infos)
def formsemestre_page_title(): def formsemestre_page_title(formsemestre_id=None):
"""Element HTML decrivant un semestre (barre de menu et infos) """Element HTML decrivant un semestre (barre de menu et infos)
Cherche dans la requete si un semestre est défini (formsemestre_id ou moduleimpl ou evaluation ou group) Cherche dans la requete si un semestre est défini (formsemestre_id ou moduleimpl ou evaluation ou group)
""" """
formsemestre_id = retreive_formsemestre_from_request() formsemestre_id = (
formsemestre_id
if formsemestre_id is not None
else retreive_formsemestre_from_request()
)
# #
if not formsemestre_id: if not formsemestre_id:
return "" return ""
try: try:
formsemestre_id = int(formsemestre_id) formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.get(formsemestre_id) except ValueError:
except: log(f"formsemestre_id: invalid type {formsemestre_id:r}")
log("can't find formsemestre_id %s" % formsemestre_id)
return "" return ""
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
h = render_template( h = render_template(
"formsemestre_page_title.html", "formsemestre_page_title.html",

View File

@ -865,7 +865,7 @@ def annee_scolaire_repr(year, month):
return "%s - %s" % (year - 1, year) return "%s - %s" % (year - 1, year)
def annee_scolaire_debut(year, month): def annee_scolaire_debut(year, month) -> int:
"""Annee scolaire de debut (septembre): heuristique pour l'hémisphère nord...""" """Annee scolaire de debut (septembre): heuristique pour l'hémisphère nord..."""
if int(month) > 7: if int(month) > 7:
return int(year) return int(year)