1
0
forked from ScoDoc/ScoDoc

Jury BUT: amélioration front et back. Voir #547. Tests YAML: refonte circuit jury. Cas lyon43. Tests ok.

This commit is contained in:
Emmanuel Viennet 2023-01-11 09:37:02 -03:00 committed by iziram
parent b8b3fbb324
commit 85f00c7cb6
12 changed files with 459 additions and 277 deletions

View File

@ -584,9 +584,10 @@ class DecisionsProposeesAnnee(DecisionsProposees):
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
le DecisionsProposeesRCUE, ou None s'il n'y en a pas
(ne devrait pas arriver car compute_rcues_annee vérifie déjà cela).
Appelé à la construction du deca, donc avant décisions manuelles.
Return: { niveau_id : DecisionsProposeesRCUE }
"""
# Retrouve le RCUE associé à chaque niveau
@ -633,6 +634,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def record_form(self, form: dict):
"""Enregistre les codes de jury en base
à partir d'un dict représentant le formulaire jury BUT:
form dict:
- 'code_ue_1896' : 'AJ' code pour l'UE id 1896
- 'code_rcue_6" : 'ADM' code pour le RCUE du niveau 6
@ -642,7 +644,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
et qu'il n'y en a pas déjà, enregistre ceux par défaut.
"""
log("jury_but.DecisionsProposeesAnnee.record_form")
with sco_cache.DeferredSemCacheManager():
code_annee = None
codes_rcues = [] # [ (dec_rcue, code), ... ]
codes_ues = [] # [ (dec_ue, code), ... ]
for key in form:
code = form[key]
# Codes d'UE
@ -652,7 +656,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
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)
codes_ues.append((dec_ue, code))
else:
# Codes de RCUE
m = re.match(r"^code_rcue_(\d+)$", key)
@ -661,12 +665,20 @@ class DecisionsProposeesAnnee(DecisionsProposees):
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)
codes_rcues.append((dec_rcue, code))
elif key == "code_annee":
# Code annuel
self.record(code)
code_annee = code
with sco_cache.DeferredSemCacheManager():
# Enregistre les codes, dans l'ordre UE, RCUE, Année
for dec_ue, code in codes_ues:
dec_ue.record(code)
for dec_rcue, code in codes_rcues:
dec_rcue.record(code)
self.record(code_annee)
self.record_all()
db.session.commit()
def record(self, code: str, no_overwrite=False):
@ -790,6 +802,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
msg=f"Validation année BUT{self.annee_but}: effacée",
)
db.session.delete(validation)
# Efface éventuelle validation de semestre
# (en principe inutilisées en BUT)
# et autres UEs (en cas de changement d'architecture de formation depuis le jury ?)
#
for validation in ScolarFormSemestreValidation.query.filter_by(
etudid=self.etud.id, formsemestre_id=self.formsemestre_id
):
db.session.delete(validation)
db.session.flush()
self.invalidate_formsemestre_cache()
@ -878,6 +899,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
inscription_etat: str = scu.INSCRIT,
):
super().__init__(etud=dec_prop_annee.etud)
self.deca = dec_prop_annee
self.rcue = rcue
if rcue is None: # RCUE non dispo, eg un seul semestre
self.codes = []
@ -928,7 +950,11 @@ class DecisionsProposeesRCUE(DecisionsProposees):
} codes={self.codes} explanation={self.explanation}"""
def record(self, code: str, no_overwrite=False):
"""Enregistre le code"""
"""Enregistre le code RCUE.
Note:
- si le RCUE est ADJ, les UE non validées sont passées à ADJ
XXX on pourra imposer ici d'autres règles de cohérence
"""
if self.rcue is None:
return # pas de RCUE a enregistrer
if self.inscription_etat != scu.INSCRIT:
@ -964,6 +990,15 @@ class DecisionsProposeesRCUE(DecisionsProposees):
msg=f"Validation {self.rcue}: {code}",
)
db.session.add(self.validation)
# Modifie au besoin les codes d'UE
if code == "ADJ":
deca = self.deca
for ue_id in (self.rcue.ue_1.id, self.rcue.ue_2.id):
dec_ue = deca.decisions_ues.get(ue_id)
if dec_ue and dec_ue.code_valide not in CODES_UE_VALIDES:
log(f"rcue.record: force ADJ sur {dec_ue}")
dec_ue.record("ADJ")
if self.rcue.formsemestre_1 is not None:
sco_cache.invalidate_formsemestre(
formsemestre_id=self.rcue.formsemestre_1.id
@ -972,6 +1007,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
sco_cache.invalidate_formsemestre(
formsemestre_id=self.rcue.formsemestre_2.id
)
self.code_valide = code # mise à jour état
self.recorded = True
def erase(self):
@ -1032,14 +1068,14 @@ class DecisionsProposeesUE(DecisionsProposees):
):
# 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(
validation = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
).first()
super().__init__(
etud=etud,
code_valide=self.validation.code if self.validation is not None else None,
code_valide=validation.code if validation is not None else None,
)
# log(f"built {self}")
self.validation = validation
self.formsemestre = formsemestre
self.ue: UniteEns = ue
self.rcue: RegroupementCoherentUE = None
@ -1082,7 +1118,7 @@ class DecisionsProposeesUE(DecisionsProposees):
def set_rcue(self, rcue: RegroupementCoherentUE):
"""Rattache cette UE à un RCUE. Cela peut modifier les codes
proposés (si compensation)"""
proposés par compute_codes() (si compensation)"""
self.rcue = rcue
def compute_codes(self):
@ -1138,6 +1174,7 @@ class DecisionsProposeesUE(DecisionsProposees):
log(f"DecisionsProposeesUE: recording {self.validation}")
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)
self.code_valide = code # mise à jour
self.recorded = True
def erase(self):

View File

@ -30,6 +30,7 @@ from app.models import (
Identite,
UniteEns,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
)
from app.scodoc import html_sco_header
from app.scodoc.sco_exceptions import ScoValueError
@ -50,7 +51,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
_gen_but_select("code_annee", deca.codes, deca.code_valide,
disabled=True, klass="manual")
}
<span>({'non ' if deca.code_valide is None else ''}enregistrée)</span>
<span>({deca.code_valide or 'non'} enregistrée)</span>
</div>
"""
)
@ -108,8 +109,8 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_pair = ues[0] if ues else None
# Les UEs à afficher, toujours en readonly
# sur le formsemestre de l'année précédente du redoublant
# Les UEs à afficher,
# qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
ues_ro = [
(
ue_impair,
@ -132,6 +133,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
deca.decisions_ues[ue.id],
disabled=read_only or ue_read_only,
annee_prec=ue_read_only,
niveau_id=ue.niveau_competence.id,
)
)
else:
@ -150,6 +152,7 @@ def _gen_but_select(
code_valide: str,
disabled: bool = False,
klass: str = "",
data: dict = {},
) -> str:
"Le menu html select avec les codes"
# if disabled: # mauvaise idée car le disabled est traité en JS
@ -165,8 +168,11 @@ def _gen_but_select(
)
return f"""<select required name="{name}"
class="but_code {klass}"
data-orig_code="{code_valide or (codes[0] if codes else '')}"
data-orig_recorded="{code_valide or ''}"
onchange="change_menu_code(this);"
{"disabled" if disabled else ""}
{" ".join( f'data-{k}="{v}"' for (k,v) in data.items() )}
>{options_htm}</select>
"""
@ -176,6 +182,7 @@ def _gen_but_niveau_ue(
dec_ue: DecisionsProposeesUE,
disabled: bool = False,
annee_prec: bool = False,
niveau_id: int = None,
) -> str:
if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]:
moy_ue_str = f"""<span class="ue_cap">{
@ -196,6 +203,13 @@ def _gen_but_niveau_ue(
"""
else:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide:
scoplement = f"""<div class="scoplement">
Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
</div>
"""
else:
scoplement = ""
return f"""<div class="but_niveau_ue {
@ -210,7 +224,9 @@ def _gen_but_niveau_ue(
<div class="but_code">{
_gen_but_select("code_ue_"+str(ue.id),
dec_ue.codes,
dec_ue.code_valide, disabled=disabled
dec_ue.code_valide,
disabled=disabled,
klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else ""
)
}</div>
@ -250,14 +266,15 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
{scoplement}
</div>
<div class="but_code">
<div>{_gen_but_select("code_rcue_"+str(niveau.id),
{_gen_but_select("code_rcue_"+str(niveau.id),
dec_rcue.codes,
dec_rcue.code_valide,
disabled=True, klass="manual"
disabled=True,
klass="manual code_rcue",
data = { "niveau_id" : str(niveau.id)}
)}
</div>
</div>
</div>
"""
@ -274,17 +291,15 @@ def jury_but_semestriel(
semestre_terminal = (
formsemestre.semestre_id >= formsemestre.formation.get_parcours().NB_SEM
)
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
etudid=etud.id,
origin_formsemestre_id=formsemestre.id,
).all()
# Par défaut: autorisé à passer dans le semestre suivant si sem. impair,
# ou si décision déjà enregistrée:
est_autorise_a_passer = (formsemestre.semestre_id % 2) or (
formsemestre.semestre_id + 1
) in (
a.semestre_id
for a in ScolarAutorisationInscription.query.filter_by(
etudid=etud.id,
origin_formsemestre_id=formsemestre.id,
)
)
) in (a.semestre_id for a in autorisations_passage)
decisions_ues = {
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
for ue in ues
@ -308,7 +323,9 @@ def jury_but_semestriel(
flash("codes enregistrés")
if not semestre_terminal:
if request.form.get("autorisation_passage"):
if not est_autorise_a_passer:
if not formsemestre.semestre_id + 1 in (
a.semestre_id for a in autorisations_passage
):
ScolarAutorisationInscription.autorise_etud(
etud.id,
formsemestre.formation.formation_code,
@ -368,21 +385,31 @@ def jury_but_semestriel(
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div>
</div>
<h3>Jury sur un semestre BUT isolé</h3>
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
{warning}
</div>
<form method="post" id="jury_but">
""",
]
if (not read_only) and any([dec.code_valide for dec in decisions_ues.values()]):
erase_span = ""
if not read_only:
# Requête toutes les validations (pas seulement celles du deca courant),
# au cas où: changement d'architecture, saisie en mode classique, ...
validations = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
).all()
if validations:
erase_span = f"""<a href="{
url_for("notes.formsemestre_jury_but_erase",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
etudid=etud.id, only_one_sem=1)
}" class="stdlink">effacer les décisions enregistrées</a>"""
else:
erase_span = "Cet étudiant n'a aucune décision enregistrée pour ce semestre."
erase_span = (
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
)
H.append(
f"""
@ -436,6 +463,9 @@ def jury_but_semestriel(
<input type="checkbox" name="autorisation_passage" value="1" {
"checked" if est_autorise_a_passer else ""}>
<em>autoriser à passer dans le semestre S{formsemestre.semestre_id+1}</em>
{("(autorisations enregistrées: " + ' '.join(
'S' + str(a.semestre_id or '') for a in autorisations_passage) + ")"
) if autorisations_passage else ""}
</input>
</div>
"""

View File

@ -68,7 +68,8 @@ class ApcValidationRCUE(db.Model):
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}</em>"""
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>"""
def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE."""

View File

@ -93,6 +93,10 @@ class ScolarAutorisationInscription(db.Model):
db.ForeignKey("notes_formsemestre.id"),
)
def __repr__(self) -> str:
return f"""{self.__class__.__name__}(id={self.id}, etudid={
self.etudid}, semestre_id={self.semestre_id})"""
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)

View File

@ -87,6 +87,8 @@ _formsemestreEditor = ndb.EditableTable(
"resp_can_edit": bool,
"resp_can_change_ens": bool,
"ens_can_edit_eval": bool,
"bul_bgcolor": lambda color: color or "white",
"titre": lambda titre: titre or "sans titre",
},
)

View File

@ -312,6 +312,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
le titre: ils seront automatiquement ajoutés <input type="button"
value="remettre titre par défaut" onClick="document.tf.titre.value='{
_default_sem_title(formation)}';"/>""",
"allow_null": False,
},
),
(

View File

@ -157,6 +157,8 @@ div.but_niveau_ue.annee_prec {
background-color: rgb(167, 167, 0);
}
div.but_section_annee,
div.but_niveau_rcue.modified,
div.but_niveau_ue.modified {
background-color: rgb(255, 214, 254);
}

View File

@ -5,17 +5,36 @@ function enable_manual_codes(elt) {
$(".jury_but select.manual").prop("disabled", !elt.checked);
}
// changement menu code:
// changement d'un menu code:
function change_menu_code(elt) {
elt.parentElement.parentElement.classList.remove("recorded");
// TODO: comparer avec valeur enregistrée (à mettre en data-orig ?)
// et colorer en fonction
// Ajuste styles pour visualiser codes enregistrés/modifiés
if (elt.value != elt.dataset.orig_code) {
elt.parentElement.parentElement.classList.add("modified");
} else {
elt.parentElement.parentElement.classList.remove("modified");
}
if (elt.value == elt.dataset.orig_recorded) {
elt.parentElement.parentElement.classList.add("recorded");
} else {
elt.parentElement.parentElement.classList.remove("recorded");
}
// Si RCUE passant en ADJ, change les menus des UEs associées
if (elt.classList.contains("code_rcue")
&& elt.dataset.niveau_id
&& elt.value == "ADJ"
&& elt.value != elt.dataset.orig_recorded) {
let ue_selects = elt.parentElement.parentElement.parentElement.querySelectorAll(
"select.ue_rcue_" + elt.dataset.niveau_id);
ue_selects.forEach(select => {
select.value = "ADJ";
change_menu_code(select); // pour changer les styles
});
}
}
$(function () {
// Recupère la liste ordonnées des etudids
// pour avoir le "suivant" etr le "précédent"
// pour avoir le "suivant" et le "précédent"
// (liens de navigation)
const url = new URL(document.URL);
const frags = url.pathname.split("/"); // .../formsemestre_validation_but/formsemestre_id/etudid

View File

@ -201,4 +201,79 @@ Etudiants:
moy_rcue: 9.50 # la moyenne courante (et non enregistrée), donc pas 10.5
est_compensable: False
decision_annee: ADM
geii43:
prenom: etugeii43
civilite: M
formsemestres:
S1:
notes_modules: # on joue avec les SAE seulement car elles sont "diagonales"
"S1.1": 9.00
"S1.2": 9.00
attendu: # les codes jury que l'on doit vérifier
deca:
passage_de_droit: False
nb_competences: 2
nb_rcue_annee: 0
decisions_ues:
"UE11":
codes: [ "AJ", "..." ]
code_valide: AJ
decision_jury: AJ
moy_ue: 9.00
"UE12":
codes: [ "AJ", "..." ]
code_valide: AJ
decision_jury: AJ
moy_ue: 9.00
S2:
notes_modules: # on joue avec les SAE seulement car elles sont "diagonales"
"S2.1": 9.00
"S2.2": 9.00
attendu: # les codes jury que l'on doit vérifier
deca:
passage_de_droit: False
nb_competences: 2
nb_rcue_annee: 2
valide_moitie_rcue: False
codes: [ "RED", "..." ]
decisions_ues:
"UE21":
codes: [ "AJ", "..." ]
code_valide: AJ
moy_ue: 9.00
"UE22":
codes: [ "AJ", "..." ]
code_valide: AJ # va basculer en ADJ car RCUE en ADJ (mais le test est AVANT !)
moy_ue: 9.00
decisions_rcues: # on repère ici les RCUE par l'acronyme de leur 1ere UE (donc du S1)
"UE11":
code_valide: AJ
decision_jury: AJ
rcue:
moy_rcue: 9.00
est_compensable: False
"UE12":
code_valide: AJ # code par défaut proposé
decision_jury: ADJ # code donné par le jury de S2
rcue:
moy_rcue: 9.00
est_compensable: False
decision_annee: RED
S1-red:
notes_modules: # on joue avec les SAE seulement car elles sont "diagonales"
"S1.1": 11.00
"S1.2": 7.00
attendu: # les codes jury que l'on doit vérifier
deca:
passage_de_droit: false
nb_competences: 2
nb_rcue_annee: 0
decisions_ues:
"UE11":
codes: [ "ADM", "..." ]
code_valide: ADM
moy_ue: 11.00
"UE12":
code_valide: AJ
moy_ue: 7.00
decision_annee: AJ

View File

@ -232,7 +232,7 @@ class ScoFake(object):
self,
formation_id=None,
semestre_id=None,
titre=None,
titre="",
date_debut=None,
date_fin=None,
etat=None,
@ -253,6 +253,7 @@ class ScoFake(object):
) -> int:
if responsables is None:
responsables = (self.default_user.id,)
titre = titre or "sans titre"
oid = sco_formsemestre.do_formsemestre_create(locals())
oids = sco_formsemestre.do_formsemestre_list(
args={"formsemestre_id": oid}

View File

@ -1,19 +1,29 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
""" Test jury BUT avec parcours
Ces tests sont généralement lents (construction de la base),
et donc marqués par `@pytest.mark.slow`.
Certains sont aussi marqués par @pytest.mark.lemans ou @pytest.mark.lyon
pour lancer certains tests spécifiques seulement.
Exemple utilisation spécifique:
# test sur "Lyon" seulement:
pytest --pdb -m lyon tests/unit/test_but_jury.py
"""
import pytest
from tests.unit import yaml_setup
import app
from app.but.jury_but import DecisionsProposeesAnnee
from app.but.jury_but_validation_auto import formsemestre_validation_auto_but
from app.models import (
Formation,
FormSemestre,
Identite,
UniteEns,
)
from app.scodoc import sco_utils as scu
from app.models import FormSemestre
from config import TestConfig
DEPT = TestConfig.DEPT_TEST

View File

@ -34,7 +34,7 @@ formsemestre_validation_auto_but(only_adm=False)
test_but_jury()
- compare décisions attendues indiquées dans le YAML avec celles de ScoDoc
et enregistre immédiatement après la décision manuelle indiquée par `decision_jury`
et enregistre immédiatement APRES la décision manuelle indiquée par `decision_jury`
dans le YAML.