diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 561ba5433..5c3d04934 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -58,9 +58,12 @@ DecisionsProposeesUE: décisions de jury sur une UE du BUT DecisionsProposeesRCUE appelera .set_compensable() si on a la possibilité de la compenser dans le RCUE. """ +import html from operator import attrgetter +import re from typing import Union +from app import db from app import log from app.comp.res_but import ResultatsSemestreBUT from app.comp import res_sem @@ -72,7 +75,7 @@ from app.models.but_refcomp import ( ApcParcours, ApcParcoursNiveauCompetence, ) -from app.models import but_validations +from app.models import Scolog from app.models.but_validations import ( ApcValidationAnnee, ApcValidationRCUE, @@ -122,10 +125,14 @@ class DecisionsProposees: self.codes = code + self.codes elif code is not None: self.codes = [code] + self.codes + self.validation = None + "Validation enregistrée" self.code_valide: str = code_valide - "La décision actuelle enregistrée" + "Code décision actuel enregistré" self.explanation: str = explanation "Explication à afficher à côté de la décision" + self.recorded = False + "true si la décision vient d'être enregistrée" def __repr__(self) -> str: return f"""<{self.__class__.__name__} valid={self.code_valide @@ -266,7 +273,15 @@ class DecisionsProposeesAnnee(DecisionsProposees): explanation: {self.explanation} """ - def annee_scolaire_sr(self) + def annee_scolaire(self) -> int: + "L'année de début de l'année scolaire" + formsemestre = self.formsemestre_impair or self.formsemestre_pair + return formsemestre.annee_scolaire() + + def annee_scolaire_str(self) -> str: + "L'année scolaire, eg '2021 - 2022'" + formsemestre = self.formsemestre_impair or self.formsemestre_pair + return formsemestre.annee_scolaire_str().replace(" ", "") def comp_formsemestres( self, formsemestre: FormSemestre @@ -397,6 +412,90 @@ class DecisionsProposeesAnnee(DecisionsProposees): decisions_rcue_by_niveau = {x[1]: x[0] for x in rc_niveaux} return decisions_rcue_by_niveau + # def lookup_ue(self, ue_id: int) -> UniteEns: + # "check that ue_id belongs to our UE, if not returns None" + # ues = [ue for ue in self.ues_impair + self.ues_pair if ue.id == ue_id] + # assert len(ues) < 2 + # if len(ues): + # return ues[0] + # return None + + def record_form(self, form: dict): + """Enregistre les codes de jury en base + form dict: + - 'code_ue_1896' : 'AJ' code pour l'UE id 1896 + - 'code_rcue_6" : 'ADM' code pour le RCUE du niveau 6 + - 'code_annee' : 'ADM' code pour l'année + + Si les code_rcue et le code_annee ne sont pas fournis, + enregistre ceux par défaut. + """ + for key in form: + code = form[key] + # Codes d'UE + m = re.match(r"^code_ue_(\d+)$", key) + if m: + ue_id = int(m.group(1)) + dec_ue = self.decisions_ues.get(ue_id) + if not dec_ue: + raise ScoValueError(f"UE invalide ue_id={ue_id}") + dec_ue.record(code) + else: + # Codes de RCUE + m = re.match(r"^code_rcue_(\d+)$", key) + if m: + niveau_id = int(m.group(1)) + dec_rcue = self.decisions_rcue_by_niveau.get(niveau_id) + if not dec_rcue: + raise ScoValueError(f"RCUE invalide niveau_id={niveau_id}") + dec_rcue.record(code) + elif key == "code_annee": + # Code annuel + self.record(code) + + self.record_all() + db.session.commit() + + def record(self, code: str): + """Enregistre le code""" + if not code in self.codes: + raise ScoValueError( + f"code annee {html.escape(code)} invalide pour formsemestre {html.escape(self.formsemestre)}" + ) + if code == self.code_valide: + return # no change + if self.validation: + db.session.delete(self.validation) + db.session.flush() + + self.validation = ApcValidationAnnee( + etudid=self.etud.id, + formsemestre=self.formsemestre_impair, + ordre=self.annee_but, + annee_scolaire=self.annee_scolaire(), + code=code, + ) + Scolog.logdb( + method="jury_but", + etudid=self.etud.id, + msg=f"Validation année BUT{self.annee_but}: {code}", + ) + db.session.add(self.validation) + self.recorded = True + + def record_all(self): + """Enregistre les codes qui n'ont pas été spécifiés par le formulaire, + et sont donc en mode "automatique" + """ + decisions = ( + list(self.decisions_ues.values()) + + list(self.decisions_rcue_by_niveau.values()) + + [self] + ) + for dec in decisions: + if not dec.recorded: + dec.record(dec.codes[0]) # rappel: le code par défaut est en tête + class DecisionsProposeesRCUE(DecisionsProposees): """Liste des codes de décisions que l'on peut proposer pour @@ -417,10 +516,10 @@ class DecisionsProposeesRCUE(DecisionsProposees): ): 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 + self.parcour = dec_prop_annee.parcour + self.validation = rcue.query_validations().first() + if self.validation is not None: + self.code_valide = self.validation.code if rcue.est_compensable(): self.codes.insert(0, sco_codes.CMP) elif rcue.est_validable(): @@ -428,6 +527,34 @@ class DecisionsProposeesRCUE(DecisionsProposees): else: self.codes.insert(0, sco_codes.AJ) + def record(self, code: str): + """Enregistre le code""" + if not code in self.codes: + raise ScoValueError( + f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" + ) + if code == self.code_valide: + return # no change + parcours_id = self.parcour.id if self.parcour is not None else None + if self.validation: + db.session.delete(self.validation) + db.session.flush() + self.validation = ApcValidationRCUE( + etudid=self.etud.id, + formsemestre_id=self.rcue.formsemestre_2.id, + ue1_id=self.rcue.ue_1.id, + ue2_id=self.rcue.ue_2.id, + parcours_id=parcours_id, + code=code, + ) + Scolog.logdb( + method="jury_but", + etudid=self.etud.id, + msg=f"Validation RCUE {repr(self.rcue)}", + ) + db.session.add(self.validation) + self.recorded = True + class DecisionsProposeesUE(DecisionsProposees): """Décisions de jury sur une UE du BUT @@ -460,6 +587,7 @@ class DecisionsProposeesUE(DecisionsProposees): ue: UniteEns, ): super().__init__(etud=etud) + self.formsemestre = formsemestre self.ue: UniteEns = ue self.rcue: RegroupementCoherentUE = None "Le rcu auquel est rattaché cette UE, ou None" @@ -503,6 +631,31 @@ class DecisionsProposeesUE(DecisionsProposees): self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes self.explanation = "notes insuffisantes" + def record(self, code: str): + """Enregistre le code""" + if not code in self.codes: + raise ScoValueError( + f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" + ) + if code == self.code_valide: + return # no change + if self.validation: + db.session.delete(self.validation) + db.session.flush() + self.validation = ScolarFormSemestreValidation( + etudid=self.etud.id, + formsemestre_id=self.formsemestre.id, + ue_id=self.ue.id, + code=code, + ) + Scolog.logdb( + method="jury_but", + etudid=self.etud.id, + msg=f"Validation UE {self.ue.id}", + ) + db.session.add(self.validation) + self.recorded = True + class BUTCursusEtud: # WIP TODO """Validation du cursus d'un étudiant""" diff --git a/app/models/but_validations.py b/app/models/but_validations.py index 51d172872..27aee4299 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -277,4 +277,4 @@ class ApcValidationAnnee(db.Model): formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees") def __repr__(self): - return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}:{self.code!r}>" + return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>" diff --git a/app/models/events.py b/app/models/events.py index b94549e76..4e566cbd9 100644 --- a/app/models/events.py +++ b/app/models/events.py @@ -32,6 +32,21 @@ class Scolog(db.Model): authenticated_user = db.Column(db.Text) # login, sans contrainte # zope_remote_addr suppressed + @classmethod + def logdb( + cls, method: str = None, etudid: int = None, msg: str = None, commit=False + ): + """Add entry in student's log (replacement for old scolog.logdb)""" + entry = Scolog( + method=method, + msg=msg, + etudid=etudid, + authenticated_user=current_user.user_name, + ) + db.session.add(entry) + if commit: + db.session.commit() + class ScolarNews(db.Model): """Nouvelles pour page d'accueil""" diff --git a/app/models/validations.py b/app/models/validations.py index 976e35f9f..00d170729 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -36,7 +36,7 @@ class ScolarFormSemestreValidation(db.Model): # NULL pour les UE, True|False pour les semestres: assidu = db.Column(db.Boolean) event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) - # NULL sauf si compense un semestre: + # NULL sauf si compense un semestre: (pas utilisé pour BUT) compense_formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), diff --git a/app/static/css/jury_but.css b/app/static/css/jury_but.css index beffba975..1182096a9 100644 --- a/app/static/css/jury_but.css +++ b/app/static/css/jury_but.css @@ -65,4 +65,28 @@ div.but_settings { span.but_explanation { color: blueviolet; font-style: italic; +} + +select:disabled { + font-weight: bold; + color: blue; +} + +select:invalid { + background: red; +} + +select.but_code option.recorded { + color: rgb(3, 157, 3); + font-weight: bold; +} + +div.but_niveau_ue.recorded, +div.but_niveau_rcue.recorded { + border-color: rgb(136, 252, 136); + border-width: 2px; +} + +div.but_niveau_ue.modified { + background-color: rgb(255, 214, 254); } \ No newline at end of file diff --git a/app/static/js/jury_but.js b/app/static/js/jury_but.js index a2f13dd18..e67362cb2 100644 --- a/app/static/js/jury_but.js +++ b/app/static/js/jury_but.js @@ -4,3 +4,11 @@ function enable_manual_codes(elt) { $(".jury_but select.manual").prop("disabled", !elt.checked); } + +// changement 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 + elt.parentElement.parentElement.classList.add("modified"); +} \ No newline at end of file diff --git a/app/views/notes.py b/app/views/notes.py index 020d1cca4..c43eb6366 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -2231,7 +2231,6 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int): formsemestre_id=formsemestre_id, ), ) - # XXX TODO Page expérimentale pour les devs H = [ html_sco_header.sco_header( page_title="Validation BUT", @@ -2244,22 +2243,37 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int):
""", ] + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) etud = Identite.query.get_or_404(etudid) res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) - + if request.method == "POST": + deca.record_form(request.form) + flash("codes enregistrés") + return flask.redirect( + url_for( + "notes.formsemestre_validation_but", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) + ) H.append( f"""
-

Jury BUT{deca.annee_but} - Parcours {deca.parcour.libelle or "non spécifié"} - - {deca.formsemestre_impair.annee_scolaire_str()}

+
+

Jury BUT{deca.annee_but} + - Parcours {deca.parcour.libelle or "non spécifié"} + - {deca.annee_scolaire_str()}

Décision de jury pour l'année : { _gen_but_select("code_annee", deca.codes, deca.code_valide, disabled=True, klass="manual") - }
+ } + ({'non ' if deca.code_valide is None else ''}enregistrée) +
{deca.explanation}
Niveaux de compétences et unités d'enseignement : @@ -2279,36 +2293,26 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int): ) dec_rcue = deca.decisions_rcue_by_niveau[niveau.id] # Semestre impair - ue = dec_rcue.rcue.ue_1 H.append( - f"""
-
{ue.acronyme}
-
{scu.fmt_note(dec_rcue.rcue.moy_ue_1)}
-
{ - _gen_but_select("code_ue_"+str(ue.id), - deca.decisions_ues[ue.id].codes, - deca.decisions_ues[ue.id].code_valide - ) - }
-
""" + _gen_but_niveau_ue( + dec_rcue.rcue.ue_1, + dec_rcue.rcue.moy_ue_1, + deca.decisions_ues[dec_rcue.rcue.ue_1.id], + ) ) # Semestre pair - ue = dec_rcue.rcue.ue_2 H.append( - f"""
-
{ue.acronyme}
-
{scu.fmt_note(dec_rcue.rcue.moy_ue_2)}
-
{ - _gen_but_select("code_ue_"+str(ue.id), - deca.decisions_ues[ue.id].codes, - deca.decisions_ues[ue.id].code_valide - ) - }
-
""" + _gen_but_niveau_ue( + dec_rcue.rcue.ue_2, + dec_rcue.rcue.moy_ue_2, + deca.decisions_ues[dec_rcue.rcue.ue_2.id], + ) ) # RCUE H.append( - f"""
+ f"""
{scu.fmt_note(dec_rcue.rcue.moy_rcue)}
{ _gen_but_select("code_rcue_"+str(niveau.id), @@ -2322,9 +2326,16 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int): H.append("
") # but_annee H.append( - """
- permettre la saisie manuelles des codes d'année et de niveaux -
""" + """
+ + permettre la saisie manuelles des codes d'année et de niveaux. + Dans ce cas, il vous revient de vous assurer de la cohérence entre + vos codes d'UE/RCUE/Année ! + +
+ + + """ ) H.append("") # but_annee @@ -2348,11 +2359,36 @@ def _gen_but_select( "Le menu html select avec les codes" h = "\n".join( [ - f"""""" + f"""""" for code in codes ] ) - return f"""""" + return f""" + """ + + +def _gen_but_niveau_ue( + ue: UniteEns, moy_ue: float, dec_ue: jury_but.DecisionsProposeesUE +): + return f"""
+
{ue.acronyme}
+
{scu.fmt_note(moy_ue)}
+
{ + _gen_but_select("code_ue_"+str(ue.id), + dec_ue.codes, + dec_ue.code_valide + ) + }
+
""" @bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"])