From e46ae763997a7357fdc3615d4f008341030eadd9 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 15 Jun 2023 08:49:05 +0200 Subject: [PATCH 01/17] =?UTF-8?q?BUT:=20jury:=20validation=20des=20niveaux?= =?UTF-8?q?=20inf=C3=A9rieurs.=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/cursus_but.py | 36 +-- app/but/jury_but.py | 223 +++++++++++++++++- app/comp/res_but.py | 5 +- app/models/but_validations.py | 12 +- app/models/formsemestre.py | 2 +- app/scodoc/codes_cursus.py | 20 +- app/scodoc/sco_apogee_csv.py | 9 +- app/scodoc/sco_formsemestre_validation.py | 7 +- ...1224fa255_validation_niveaux_inferieurs.py | 63 +++++ sco_version.py | 2 +- tests/ressources/yaml/cursus_but_gccd_cy.yaml | 129 ++++++---- 11 files changed, 414 insertions(+), 94 deletions(-) create mode 100644 migrations/versions/c701224fa255_validation_niveaux_inferieurs.py diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 4654a9cb5..89a7bd669 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -104,7 +104,7 @@ class EtudCursusBUT: self.parcour: ApcParcours = self.inscriptions[-1].parcour "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)" self.niveaux_by_annee = {} - "{ annee : liste des niveaux à valider }" + "{ annee:int : liste des niveaux à valider }" self.niveaux: dict[int, ApcNiveau] = {} "cache les niveaux" for annee in (1, 2, 3): @@ -118,21 +118,6 @@ class EtudCursusBUT: self.niveaux.update( {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]} ) - # Probablement inutile: - # # Cherche les validations de jury enregistrées pour chaque niveau - # self.validations_by_niveau = collections.defaultdict(lambda: []) - # " { niveau_id : [ ApcValidationRCUE ] }" - # for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): - # self.validations_by_niveau[validation_rcue.niveau().id].append( - # validation_rcue - # ) - # self.validation_by_niveau = { - # niveau_id: sorted( - # validations, key=lambda v: sco_codes.BUT_CODES_ORDERED[v.code] - # )[0] - # for niveau_id, validations in self.validations_by_niveau.items() - # } - # "{ niveau_id : meilleure validation pour ce niveau }" self.validation_par_competence_et_annee = {} """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" @@ -146,7 +131,7 @@ class EtudCursusBUT: # prend la "meilleure" validation if (not previous_validation) or ( sco_codes.BUT_CODES_ORDERED[validation_rcue.code] - > sco_codes.BUT_CODES_ORDERED[previous_validation["code"]] + > sco_codes.BUT_CODES_ORDERED[previous_validation.code] ): self.validation_par_competence_et_annee[niveau.competence.id][ niveau.annee @@ -206,6 +191,23 @@ class EtudCursusBUT: ) return d + def load_validation_by_niveau(self) -> dict[int, list[ApcValidationRCUE]]: + """Cherche les validations de jury enregistrées pour chaque niveau + Résultat: { niveau_id : [ ApcValidationRCUE ] } + meilleure validation pour ce niveau + """ + validations_by_niveau = collections.defaultdict(lambda: []) + for validation_rcue in ApcValidationRCUE.query.filter_by(etud=self.etud): + validations_by_niveau[validation_rcue.niveau().id].append(validation_rcue) + validation_by_niveau = { + niveau_id: sorted( + validations, key=lambda v: sco_codes.BUT_CODES_ORDERED[v.code] + )[0] + for niveau_id, validations in validations_by_niveau.items() + if validations + } + return validation_by_niveau + class FormSemestreCursusBUT: """L'état des étudiants d'un formsemestre dans leur cursus BUT diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 1caa44118..12e14bfb8 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -58,6 +58,7 @@ 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. """ +from datetime import datetime import html from operator import attrgetter import re @@ -68,6 +69,7 @@ from flask import flash, g, url_for from app import db from app import log +from app.but.cursus_but import EtudCursusBUT from app.comp.res_but import ResultatsSemestreBUT from app.comp import res_sem @@ -92,6 +94,7 @@ from app.models.validations import ScolarFormSemestreValidation from app.scodoc import sco_cache from app.scodoc import codes_cursus as sco_codes from app.scodoc.codes_cursus import ( + code_rcue_validant, BUT_CODES_ORDERED, CODES_RCUE_VALIDES, CODES_UE_CAPITALISANTS, @@ -275,6 +278,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): if self.formsemestre_impair is not None: self.validation = ApcValidationAnnee.query.filter_by( etudid=self.etud.id, + formation_id=self.formsemestre.formation_id, formsemestre_id=formsemestre_impair.id, ordre=self.annee_but, ).first() @@ -755,6 +759,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): self.validation = ApcValidationAnnee( etudid=self.etud.id, formsemestre=self.formsemestre_impair, + formation_id=self.formsemestre.formation_id, ordre=self.annee_but, annee_scolaire=self.annee_scolaire(), code=code, @@ -900,6 +905,9 @@ class DecisionsProposeesAnnee(DecisionsProposees): ) validations = ApcValidationAnnee.query.filter_by( etudid=self.etud.id, + # XXX efface les validations émise depuis ce semestre + # et pas toutes celles concernant cette l'année... + # (utiliser formation_id pour changer cette politique) formsemestre_id=self.formsemestre_impair.id, ordre=self.annee_but, ) @@ -1035,6 +1043,9 @@ class DecisionsProposeesRCUE(DecisionsProposees): ): super().__init__(etud=dec_prop_annee.etud) self.deca = dec_prop_annee + self.referentiel_competence_id = ( + self.deca.formsemestre.formation.referentiel_competence_id + ) self.rcue = rcue if rcue is None: # RCUE non dispo, eg un seul semestre self.codes = [] @@ -1139,7 +1150,8 @@ class DecisionsProposeesRCUE(DecisionsProposees): dec_ue.record(sco_codes.ADJR) # Valide les niveaux inférieurs de la compétence (code ADSUP) - # TODO + if code in CODES_RCUE_VALIDES: + self.valide_niveau_inferieur() if self.rcue.formsemestre_1 is not None: sco_cache.invalidate_formsemestre( @@ -1177,6 +1189,189 @@ class DecisionsProposeesRCUE(DecisionsProposees): return f"{niveau_titre}-{ordre}" return "" + def valide_niveau_inferieur(self) -> None: + """Appelé juste après la validation d'un RCUE. + *La validation des deux UE du niveau d’une compétence emporte la validation de + l’ensemble des UEs du niveau inférieur de cette même compétence.* + """ + if not self.rcue or not self.rcue.ue_1 or not self.rcue.ue_1.niveau_competence: + return + competence: ApcCompetence = self.rcue.ue_1.niveau_competence.competence + ordre_inferieur = self.rcue.ue_1.niveau_competence.ordre - 1 + if ordre_inferieur < 1: + return # pas de niveau inferieur + + # --- Si le RCUE inférieur est déjà validé, ne fait rien + validations_rcue = ( + ApcValidationRCUE.query.filter_by(etudid=self.etud.id) + .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id) + .join(ApcNiveau) + .filter_by(ordre=ordre_inferieur) + .join(ApcCompetence) + .filter_by(id=competence.id) + .all() + ) + if [v for v in validations_rcue if code_rcue_validant(v.code)]: + return # déjà validé + + # --- Validations des UEs + ues, ue1, ue2 = self._get_ues_inferieures(competence, ordre_inferieur) + # Pour chaque UE inférieure non validée, valide: + for ue in ues: + validations_ue = ScolarFormSemestreValidation.query.filter_by( + etudid=self.etud.id, ue_id=ue.id + ).all() + if [ + validation + for validation in validations_ue + if sco_codes.code_ue_validant(validation.code) + ]: + continue # on a déjà une validation + # aucune validation validante + validation_ue = validations_ue[0] if validations_ue else None + if validation_ue: + # Modifie validation existante + validation_ue.code = sco_codes.ADSUP + validation_ue.event_date = datetime.now() + if validation_ue.formsemestre_id is not None: + sco_cache.invalidate_formsemestre( + formsemestre_id=validation_ue.formsemestre_id + ) + log(f"updating {validation_ue}") + else: + # Ajoute une validation, + # pas de formsemestre ni de note car pas une capitalisation + validation_ue = ScolarFormSemestreValidation( + etudid=self.etud.id, + code=sco_codes.ADSUP, + ue_id=ue.id, + is_external=True, # pas rattachée à un formsemestre + ) + log(f"recording {validation_ue}") + db.session.add(validation_ue) + + # Valide le RCUE inférieur + if validations_rcue: + # Met à jour validation existante + validation_rcue = validations_rcue[0] + validation_rcue.code = sco_codes.ADSUP + validation_rcue.date = datetime.now() + log(f"updating {validation_rcue}") + if validation_rcue.formsemestre_id is not None: + sco_cache.invalidate_formsemestre( + formsemestre_id=validation_rcue.formsemestre_id + ) + else: + # Crée nouvelle validation + validation_rcue = ApcValidationRCUE( + etudid=self.etud.id, ue1_id=ue1.id, ue2_id=ue2.id, code=sco_codes.ADSUP + ) + log(f"recording {validation_rcue}") + db.session.add(validation_rcue) + db.session.commit() + self.valide_annee_inferieure() + + def valide_annee_inferieure(self) -> None: + """Si tous les RCUEs de l'année inférieure sont validés, la valide""" + # Indice de l'année inférieure: + annee_courante = self.rcue.ue_1.niveau_competence.annee # "BUT2" + if not re.match(r"^BUT\d$", annee_courante): + log("Warning: valide_annee_inferieure invalid annee_courante") + return + annee_inferieure = int(annee_courante[3]) - 1 + if annee_inferieure < 1: + return + # Garde-fou: Année déjà validée ? + validations_annee: ApcValidationAnnee = ApcValidationAnnee.query.filter_by( + etudid=self.etud.id, + ordre=annee_inferieure, + formation_id=self.rcue.formsemestre_1.formation_id, + ).all() + if len(validations_annee) > 1: + log( + f"warning: {len(validations_annee)} validations d'année\n{validations_annee}" + ) + if [ + validation_annee + for validation_annee in validations_annee + if sco_codes.code_annee_validant(validation_annee.code) + ]: + return # déja valide + validation_annee = validations_annee[0] if validations_annee else None + # Liste des niveaux à valider: + # ici on sort l'artillerie lourde + cursus: EtudCursusBUT = EtudCursusBUT( + self.etud, self.rcue.formsemestre_1.formation + ) + niveaux_a_valider = cursus.niveaux_by_annee[annee_inferieure] + # Pour chaque niveau, cherche validation RCUE + validations_by_niveau = cursus.load_validation_by_niveau() + ok = True + for niveau in niveaux_a_valider: + validation_niveau: ApcValidationRCUE = validations_by_niveau.get(niveau.id) + if not validation_niveau or not sco_codes.code_rcue_validant( + validation_niveau.code + ): + ok = False + + # Si tous OK, émet validation année + if validation_annee: # Modifie la validation antérieure (non validante) + validation_annee.code = sco_codes.ADSUP + validation_annee.date = datetime.now() + log(f"updating {validation_annee}") + else: + validation_annee = ApcValidationAnnee( + etudid=self.etud.id, + ordre=annee_inferieure, + code=sco_codes.ADSUP, + formation_id=self.rcue.formsemestre_1.formation_id, + # met cette validation sur l'année scolaire actuelle, pas la précédente (??) + annee_scolaire=self.rcue.formsemestre_1.annee_scolaire(), + ) + log(f"recording {validation_annee}") + db.session.add(validation_annee) + db.session.commit() + + def _get_ues_inferieures( + self, competence: ApcCompetence, ordre_inferieur: int + ) -> tuple[list[UniteEns], UniteEns, UniteEns]: + """Les UEs de cette formation associées au niveau de compétence inférieur ? + Note: on ne cherche que dans la formation courante, pas les UEs de + même code d'autres formations. + """ + formation: Formation = self.rcue.formsemestre_1.formation + ues: list[UniteEns] = ( + UniteEns.query.filter_by(formation_id=formation.id) + .filter(UniteEns.semestre_idx != None) + .join(ApcNiveau) + .filter_by(ordre=ordre_inferieur) + .join(ApcCompetence) + .filter_by(id=competence.id) + .all() + ) + log(f"valide_niveau_inferieur: {competence} UEs inférieures: {ues}") + if len(ues) != 2: # on n'a pas 2 UE associées au niveau inférieur ! + flash( + "Impossible de valider le niveau de compétence inférieur: pas 2 UEs associées'", + "warning", + ) + return + ues_impaires = [ue for ue in ues if ue.semestre_idx % 2] + if len(ues_impaires) != 1: + flash( + "Impossible de valider le niveau de compétence inférieur: pas d'UE impaire associée" + ) + return + ue1 = ues_impaires[0] + ues_paires = [ue for ue in ues if not ue.semestre_idx % 2] + if len(ues_paires) != 1: + flash( + "Impossible de valider le niveau de compétence inférieur: pas d'UE paire associée" + ) + return + ue2 = ues_paires[0] + return ues, ue1, ue2 + class DecisionsProposeesUE(DecisionsProposees): """Décisions de jury sur une UE du BUT @@ -1383,23 +1578,29 @@ class BUTCursusEtud: # WIP TODO 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. + def est_annee_validee(self, ordre: int) -> bool: + """Vrai si l'année BUT ordre est validée""" + # On cherche les validations d'annee avec le même + # code 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) + ApcValidationAnnee.query.filter_by( + etudid=self.etud.id, + ordre=ordre, + formation_id=self.formsemestre.formation_id, + ) + .join(Formation) .filter( - Formation.referentiel_competence_id - == self.formsemestre.formation.referentiel_competence_id + Formation.formation_code == self.formsemestre.formation.formation_code ) .count() > 0 ) + def est_diplome(self) -> bool: + """Vrai si BUT déjà validé""" + # vrai si la troisième année est validée + return self.est_annee_validee(3) + def competences_du_parcours(self) -> list[ApcCompetence]: """Construit liste des compétences du parcours, qui doivent être validées pour obtenir le diplôme. diff --git a/app/comp/res_but.py b/app/comp/res_but.py index e4602327b..a91b1dbbc 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -307,9 +307,10 @@ class ResultatsSemestreBUT(NotesTableCompat): return ues_ids def etud_has_decision(self, etudid) -> bool: - """True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre. + """True s'il y a une décision (quelconque) de jury + émanant de ce formsemestre pour cet étudiant. prend aussi en compte les autorisations de passage. - Sous-classée en BUT pour les RCUEs et années. + Ici sous-classée (BUT) pour les RCUEs et années. """ return bool( super().etud_has_decision(etudid) diff --git a/app/models/but_validations.py b/app/models/but_validations.py index a21cd071f..d9b0e7e2d 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -320,7 +320,12 @@ class ApcValidationAnnee(db.Model): 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 + formation_id = db.Column( + db.Integer, + db.ForeignKey("notes_formations.id"), + nullable=False, + ) + annee_scolaire = db.Column(db.Integer, nullable=False) # eg 2021 date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True) @@ -348,7 +353,7 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict: """ Un dict avec les décisions de jury BUT enregistrées: - decision_rcue : list[dict] - - decision_annee : dict + - decision_annee : dict (décision issue de ce semestre seulement (à confirmer ?)) Ne reprend pas les décisions d'UE, non spécifiques au BUT. """ decisions = {} @@ -383,8 +388,7 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict: etudid=etud.id, annee_scolaire=formsemestre.annee_scolaire(), ) - .join(ApcValidationAnnee.formsemestre) - .join(FormSemestre.formation) + .join(Formation) .filter(Formation.formation_code == formsemestre.formation.formation_code) .first() ) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 680e3ff98..516d8fc51 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -859,7 +859,7 @@ class FormSemestre(db.Model): .order_by(UniteEns.numero) .all() ) - vals_annee = ( + vals_annee = ( # issues de ce formsemestre seulement ApcValidationAnnee.query.filter_by( etudid=etudid, annee_scolaire=self.annee_scolaire(), diff --git a/app/scodoc/codes_cursus.py b/app/scodoc/codes_cursus.py index f2285ac12..228fb6d25 100644 --- a/app/scodoc/codes_cursus.py +++ b/app/scodoc/codes_cursus.py @@ -122,6 +122,7 @@ ABAN = "ABAN" ABL = "ABL" ADM = "ADM" # moyenne gen., barres UE, assiduité: sem. validé ADC = "ADC" # admis par compensation (eg moy(S1, S2) > 10) +ADSUP = "ADSUP" # BUT: UE ou RCUE validé par niveau supérieur ADJ = "ADJ" # admis par le jury ADJR = "ADJR" # UE admise car son RCUE est ADJ ATT = "ATT" # @@ -162,6 +163,7 @@ CODES_EXPL = { ADJ: "Validé par le Jury", ADJR: "UE validée car son RCUE est validé ADJ par le jury", ADM: "Validé", + ADSUP: "UE ou RCUE validé car le niveau supérieur est validé", AJ: "Ajourné (ou UE/BC de BUT en attente pour problème de moyenne)", ATB: "Décision en attente d'un autre semestre (au moins une UE sous la barre)", ATJ: "Décision en attente d'un autre semestre (assiduité insuffisante)", @@ -195,17 +197,18 @@ CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente CODES_SEM_REO = {NAR} # reorientation CODES_UE_VALIDES_DE_DROIT = {ADM, CMP} # validation "de droit" -CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR} +CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR, ADSUP} "UE validée" CODES_UE_CAPITALISANTS = {ADM} "UE capitalisée" CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP} -CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ} +CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ, ADSUP} "Niveau RCUE validé" # Pour le BUT: -CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD} +CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM} # PASD était ici mais retiré en juin 23 +CODES_ANNEE_BUT_VALIDES = {ADM, ADSUP} CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL} BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE @@ -229,6 +232,7 @@ BUT_CODES_ORDERED = { PASD: 50, PAS1NCI: 60, ADJR: 90, + ADSUP: 90, ADJ: 100, ADM: 100, } @@ -249,6 +253,16 @@ def code_ue_validant(code: str) -> bool: return code in CODES_UE_VALIDES +def code_rcue_validant(code: str) -> bool: + "Vrai si ce code d'RCUE est validant" + return code in CODES_RCUE_VALIDES + + +def code_annee_validant(code: str) -> bool: + "Vrai si code d'année BUT validant" + return code in CODES_ANNEE_BUT_VALIDES + + DEVENIR_EXPL = { NEXT: "Passage au semestre suivant", REDOANNEE: "Redoublement année", diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index 898647e5a..1c02c3c16 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -473,7 +473,10 @@ class ApoEtud(dict): ) def _but_load_validation_annuelle(self): - "charge la validation de jury BUT annuelle" + """charge la validation de jury BUT annuelle. + Ici impose qu'elle soit issue d'un semestre de l'année en cours + (pas forcément nécessaire, voir selon les retours des équipes ?) + """ # le semestre impair de l'année scolaire if self.cur_res.formsemestre.semestre_id % 2: formsemestre = self.cur_res.formsemestre @@ -490,7 +493,9 @@ class ApoEtud(dict): return self.validation_annee_but: ApcValidationAnnee = ( ApcValidationAnnee.query.filter_by( - formsemestre_id=formsemestre.id, etudid=self.etud["etudid"] + formsemestre_id=formsemestre.id, + etudid=self.etud["etudid"], + formation_id=self.cur_sem["formation_id"], ).first() ) self.is_nar = ( diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index b2686445a..3a173f037 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -66,6 +66,7 @@ from app.scodoc import sco_photos from app.scodoc import sco_preferences from app.scodoc import sco_pv_dict + # ------------------------------------------------------------------------------------ def formsemestre_validation_etud_form( formsemestre_id=None, # required @@ -1063,8 +1064,6 @@ def formsemestre_validate_previous_ue(formsemestre_id, etudid): """Form. saisie UE validée hors ScoDoc (pour étudiants arrivant avec un UE antérieurement validée). """ - from app.scodoc import sco_formations - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] sem = sco_formsemestre.get_formsemestre(formsemestre_id) formation: Formation = Formation.query.get_or_404(sem["formation_id"]) @@ -1087,8 +1086,8 @@ def formsemestre_validate_previous_ue(formsemestre_id, etudid): dans un semestre hors ScoDoc.

Les UE validées dans ScoDoc sont déjà automatiquement prises en compte. Cette page n'est utile que pour les étudiants ayant - suivi un début de cursus dans un autre établissement, ou bien dans un semestre géré sans - ScoDoc et qui redouble ce semestre + suivi un début de cursus dans un autre établissement, ou bien dans un semestre géré + sans ScoDoc et qui redouble ce semestre (ne pas utiliser pour les semestres précédents !).

Notez que l'UE est validée, avec enregistrement immédiat de la décision et diff --git a/migrations/versions/c701224fa255_validation_niveaux_inferieurs.py b/migrations/versions/c701224fa255_validation_niveaux_inferieurs.py new file mode 100644 index 000000000..08f275091 --- /dev/null +++ b/migrations/versions/c701224fa255_validation_niveaux_inferieurs.py @@ -0,0 +1,63 @@ +"""validation niveaux inferieurs + +Revision ID: c701224fa255 +Revises: d84bc592584e +Create Date: 2023-06-11 11:08:05.553898 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker # added by ev + +# revision identifiers, used by Alembic. +revision = "c701224fa255" +down_revision = "d84bc592584e" +branch_labels = None +depends_on = None + +Session = sessionmaker() + + +def upgrade(): + # Ajoute la colonne formation_id, nullable, la peuple puis la rend non nullable + op.add_column( + "apc_validation_annee", sa.Column("formation_id", sa.Integer(), nullable=True) + ) + op.create_foreign_key( + "apc_validation_annee_formation_id_fkey", + "apc_validation_annee", + "notes_formations", + ["formation_id"], + ["id"], + ) + + # Affecte la formation des anciennes validations + bind = op.get_bind() + session = Session(bind=bind) + session.execute( + sa.text( + """ + UPDATE apc_validation_annee AS a + SET formation_id = ( + SELECT f.id + FROM notes_formations f + JOIN notes_formsemestre s ON f.id = s.formation_id + WHERE s.id = a.formsemestre_id + ) + WHERE a.formsemestre_id IS NOT NULL; + """ + ) + ) + op.alter_column( + "apc_validation_annee", + "formation_id", + nullable=False, + ) + + +def downgrade(): + with op.batch_alter_table("apc_validation_annee", schema=None) as batch_op: + batch_op.drop_constraint( + "apc_validation_annee_formation_id_fkey", type_="foreignkey" + ) + batch_op.drop_column("formation_id") diff --git a/sco_version.py b/sco_version.py index 28f9304a4..8d1008831 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.83" +SCOVERSION = "9.4.84" SCONAME = "ScoDoc" diff --git a/tests/ressources/yaml/cursus_but_gccd_cy.yaml b/tests/ressources/yaml/cursus_but_gccd_cy.yaml index 56446387a..668951d2c 100644 --- a/tests/ressources/yaml/cursus_but_gccd_cy.yaml +++ b/tests/ressources/yaml/cursus_but_gccd_cy.yaml @@ -1,122 +1,122 @@ # Tests unitaires jury BUT # Essais avec un BUT GCCD (GC-CD) et un parcours de S1 à S6 -# Le GCCD est un programme à 5 compétences, dont certaines +# Le GCCD est un programme à 5 compétences, dont certaines # terminent en S4 ou en S6 selon les parcours. ReferentielCompetences: - filename: but-GCCD-05012022-081630.xml + filename: but-GCCD-05012022-081630.xml specialite: GCCD Formation: filename: scodoc_formation_BUT_GC-CD_v2.xml # Association des UEs aux compétences: ues: - # S1 tronc commun: - 'UE1.1': + # S1 tronc commun: + "UE1.1": annee: BUT1 competence: "Solutions Bâtiment" - 'UE1.2': + "UE1.2": annee: BUT1 competence: "Solutions TP" - 'UE1.3': + "UE1.3": annee: BUT1 competence: "Dimensionner" - 'UE1.4': + "UE1.4": annee: BUT1 competence: Organiser - 'UE1.5': + "UE1.5": annee: BUT1 competence: Piloter - # S2 tronc commun: - 'UE2.1': + # S2 tronc commun: + "UE2.1": annee: BUT1 competence: "Solutions Bâtiment" - 'UE2.2': + "UE2.2": annee: BUT1 competence: "Solutions TP" - 'UE2.3': + "UE2.3": annee: BUT1 competence: "Dimensionner" - 'UE2.4': + "UE2.4": annee: BUT1 competence: Organiser - 'UE2.5': + "UE2.5": annee: BUT1 competence: Piloter - + # S3 : Tronc commun - 'UE3.1': + "UE3.1": annee: BUT2 competence: "Solutions Bâtiment" - 'UE3.2': + "UE3.2": annee: BUT2 competence: "Solutions TP" - 'UE3.3': + "UE3.3": annee: BUT2 competence: "Dimensionner" - 'UE3.4': + "UE3.4": annee: BUT2 competence: Organiser - 'UE3.5': + "UE3.5": annee: BUT2 competence: Piloter # S4 Tronc commun - 'UE4.1': + "UE4.1": annee: BUT2 competence: "Solutions Bâtiment" - 'UE4.2': + "UE4.2": annee: BUT2 competence: "Solutions TP" - 'UE4.3': + "UE4.3": annee: BUT2 competence: "Dimensionner" - 'UE4.4': + "UE4.4": annee: BUT2 competence: Organiser - 'UE4.5': + "UE4.5": annee: BUT2 competence: Piloter # S5 Parcours BAT + TP - 'UE5.1': # Parcours BAT seulement + "UE5.1": # Parcours BAT seulement annee: BUT3 parcours: BAT # + RAPEB, BEC competence: "Solutions Bâtiment" - 'UE5.2': # Parcours TP seulement + "UE5.2": # Parcours TP seulement annee: BUT3 parcours: TP # + BEC competence: "Solutions TP" - 'UE5.3': + "UE5.3": annee: BUT3 parcours: [RAPEB, BEC] competence: "Dimensionner" - 'UE5.4': + "UE5.4": annee: BUT3 parcours: [BAT, TP] competence: Organiser - 'UE5.5': + "UE5.5": annee: BUT3 parcours: [BAT, TP] competence: Piloter # S6 Parcours BAT + TP - 'UE6.1': # Parcours BAT seulement + "UE6.1": # Parcours BAT seulement annee: BUT3 parcours: BAT # + RAPEB, BEC competence: "Solutions Bâtiment" - 'UE6.2': # Parcours TP seulement + "UE6.2": # Parcours TP seulement annee: BUT3 - parcours: [TP,BEC] + parcours: [TP, BEC] competence: "Solutions TP" - 'UE6.3': + "UE6.3": annee: BUT3 - parcours: [RAPEB,BEC] + parcours: [RAPEB, BEC] competence: "Dimensionner" - 'UE6.4': + "UE6.4": annee: BUT3 parcours: [BAT, TP] competence: Organiser - 'UE6.5': + "UE6.5": annee: BUT3 - parcours: [BAT,TP] + parcours: [BAT, TP] competence: Piloter modules_parcours: @@ -126,8 +126,8 @@ Formation: # - tous les module de S1 à S4 dans tous les parcours # - SAE communes en S1 et S2 mais différenciées par parcours ensuite # - en S5, ressources différenciées: on ne les mentionne pas toutes ici - BAT: [ "R[1-4].*", "SAÉ [1-2]", "SAÉ *.BAT.*", "R5.0[1-7]", "R5.14" ] - TP: [ "R[1-4].*", "SAÉ [1-2]", "SAÉ *.TP.*", "R5.0[1-4]", "R5.0[89]" ] + BAT: ["R[1-4].*", "SAÉ [1-2]", "SAÉ *.BAT.*", "R5.0[1-7]", "R5.14"] + TP: ["R[1-4].*", "SAÉ [1-2]", "SAÉ *.TP.*", "R5.0[1-4]", "R5.0[89]"] FormSemestres: # S1 et S2 avec les parcours BAT et TP: @@ -135,32 +135,32 @@ FormSemestres: idx: 1 date_debut: 2021-09-01 date_fin: 2022-01-15 - codes_parcours: ['BAT', 'TP'] - S2: + codes_parcours: ["BAT", "TP"] + S2: idx: 2 date_debut: 2022-01-15 date_fin: 2022-06-30 - codes_parcours: ['BAT', 'TP'] + codes_parcours: ["BAT", "TP"] S3: idx: 3 date_debut: 2022-09-01 date_fin: 2023-01-15 - codes_parcours: ['BAT', 'TP'] + codes_parcours: ["BAT", "TP"] S4: idx: 4 date_debut: 2023-01-16 date_fin: 2023-06-30 - codes_parcours: ['BAT', 'TP'] + codes_parcours: ["BAT", "TP"] S5: idx: 5 date_debut: 2023-09-01 date_fin: 2024-01-15 - codes_parcours: ['BAT', 'TP'] + codes_parcours: ["BAT", "TP"] S6: idx: 6 date_debut: 2024-01-16 date_fin: 2024-06-30 - codes_parcours: ['BAT', 'TP'] + codes_parcours: ["BAT", "TP"] Etudiants: A_ok: # Etudiant parcours BAT qui va tout valider directement @@ -171,10 +171,18 @@ Etudiants: parcours: BAT notes_modules: "R1.01": 11 # toutes UEs + "SAÉ 1-2": EXC S2: parcours: BAT notes_modules: "R2.01": 12 # toutes UEs + attendu: # les codes jury que l'on doit vérifier + deca: + passage_de_droit: True + autorisations_inscription: [3] + code_valide: + nb_competences: 5 + nb_rcue_annee: 4 S3: parcours: BAT notes_modules: @@ -186,7 +194,7 @@ Etudiants: S5: parcours: BAT - dispense_ues: ['UE5.2', 'UE5.3'] + dispense_ues: ["UE5.2", "UE5.3"] notes_modules: "R5.01": 15 # toutes UE "SAÉ 5.BAT.01": 10 # UE5.1 @@ -202,6 +210,7 @@ Etudiants: parcours: TP notes_modules: "R1.01": 11 # toutes UEs + "SAÉ 1-2": EXC S2: parcours: TP notes_modules: @@ -217,10 +226,32 @@ Etudiants: S5: parcours: TP - dispense_ues: ['UE5.1', 'UE5.3'] + dispense_ues: ["UE5.1", "UE5.3"] notes_modules: "R5.01": 15 # toutes UE "SAÉ 5.BAT.01": 10 # UE5.1 "SAÉ 5.BAT.02": 11 # UE5.4 S6: parcours: TP + + C: # Etudiant qui passe sans un RCUE et valide en BUT2 + prenom: Étudiant_TP_but2 + civilite: M + formsemestres: + S1: + parcours: TP + notes_modules: + "R1.01": 11 # toutes UEs + "SAÉ 1-2": 8 # plombe l'UE 2 + S2: + parcours: TP + notes_modules: + "R2.01": 11 # toutes UEs + S3: + parcours: TP + notes_modules: + "R3.01": 12 # toutes UEs + S4: + parcours: TP + notes_modules: + "R4.01": 14 # toutes UE From 008dd9b50ef1771945ba0cd7568b23dd16f1c63e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 15 Jun 2023 16:50:22 +0200 Subject: [PATCH 02/17] =?UTF-8?q?Fix=20enregistrement=20jury=20ann=C3=A9e?= =?UTF-8?q?=20BUT=20et=20passage=20en=20mode=20auto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 13 +++++++++---- app/scodoc/codes_cursus.py | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 12e14bfb8..227ead81c 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -279,7 +279,6 @@ class DecisionsProposeesAnnee(DecisionsProposees): self.validation = ApcValidationAnnee.query.filter_by( etudid=self.etud.id, formation_id=self.formsemestre.formation_id, - formsemestre_id=formsemestre_impair.id, ordre=self.annee_but, ).first() else: @@ -417,6 +416,9 @@ class DecisionsProposeesAnnee(DecisionsProposees): self.formsemestre_impair and self.formsemestre_impair.modalite == "EXT" ) or (self.formsemestre_pair and self.formsemestre_pair.modalite == "EXT"): self.codes.insert(0, sco_codes.ADM) + # Si validée par niveau supérieur: + if self.code_valide == sco_codes.ADSUP: + self.codes.insert(0, sco_codes.ADSUP) self.explanation = f"

{expl_rcues}
" messages = self.descr_pb_coherence() if messages: @@ -730,16 +732,18 @@ class DecisionsProposeesAnnee(DecisionsProposees): dec_ue.record(code) for dec_rcue, code in codes_rcues: dec_rcue.record(code) - self.record(code_annee) + self.record(code_annee, mark_recorded=False) self.record_autorisation_inscription(code_annee) self.record_all() + self.recorded = True db.session.commit() - def record(self, code: str, no_overwrite=False) -> bool: + def record(self, code: str, no_overwrite=False, mark_recorded: bool = True) -> bool: """Enregistre le code de l'année, et au besoin l'autorisation d'inscription. Si no_overwrite, ne fait rien si un code est déjà enregistré. Si l'étudiant est DEM ou DEF, ne fait rien. + Si mark_recorded est vrai, positionne self.recorded """ if self.inscription_etat != scu.INSCRIT: return False @@ -771,7 +775,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): etudid=self.etud.id, msg=f"Validation année BUT{self.annee_but}: {code}", ) - self.recorded = True + if mark_recorded: + self.recorded = True self.invalidate_formsemestre_cache() return True diff --git a/app/scodoc/codes_cursus.py b/app/scodoc/codes_cursus.py index 228fb6d25..6c4336a42 100644 --- a/app/scodoc/codes_cursus.py +++ b/app/scodoc/codes_cursus.py @@ -207,7 +207,7 @@ CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ, ADSUP} "Niveau RCUE validé" # Pour le BUT: -CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM} # PASD était ici mais retiré en juin 23 +CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD} # PASD pour enregistrement auto CODES_ANNEE_BUT_VALIDES = {ADM, ADSUP} CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL} BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE From 44dffea8d29843b1c84199cfee8f02fb2edc0d40 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 15 Jun 2023 17:14:37 +0200 Subject: [PATCH 03/17] Fix: tri des coefs. de modules apc --- app/models/modules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/modules.py b/app/models/modules.py index cfc6a994f..85503f278 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -242,7 +242,7 @@ class Module(db.Model): "les coefs d'UE, trié par numéro et acronyme d'UE" # je n'ai pas su mettre un order_by sur le backref sans avoir # à redéfinir les relationships... - return sorted(self.ue_coefs, key=attrgetter("numero", "acronyme")) + return sorted(self.ue_coefs, key=lambda uc: (uc.ue.numero, uc.ue.acronyme)) def ue_coefs_list( self, include_zeros=True, ues: list["UniteEns"] = None From 021b4ec5f880cf428ed419f6f44dea214167c898 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 15 Jun 2023 21:53:05 +0200 Subject: [PATCH 04/17] =?UTF-8?q?Jury=20BUT:=20condition=20de=20passage=20?= =?UTF-8?q?de=20S5:=20toutes=20UEs=20de=20BUT1=20valid=C3=A9es.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/cursus_but.py | 39 +++++++++++++++++-- app/but/jury_but.py | 39 ++++++++++++++----- app/models/validations.py | 2 +- tests/ressources/yaml/cursus_but_gccd_cy.yaml | 23 +++++++++++ tests/unit/yaml_setup.py | 3 +- 5 files changed, 92 insertions(+), 14 deletions(-) diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 89a7bd669..d8ca17e15 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -14,6 +14,7 @@ Classe raccordant avec ScoDoc 7: """ import collections +from operator import attrgetter from typing import Union from flask import g, url_for @@ -23,8 +24,6 @@ from app import log from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_compat import NotesTableCompat -from app.comp import res_sem - from app.models.but_refcomp import ( ApcAnneeParcours, ApcCompetence, @@ -45,7 +44,7 @@ from app.models.formsemestre import FormSemestre, FormSemestreInscription from app.models.ues import UniteEns from app.models.validations import ScolarFormSemestreValidation from app.scodoc import codes_cursus as sco_codes -from app.scodoc.codes_cursus import RED, UE_STANDARD +from app.scodoc.codes_cursus import code_ue_validant, RED, UE_STANDARD from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError @@ -360,6 +359,40 @@ class FormSemestreCursusBUT: "cache { competence_id : competence }" +def etud_ues_de_but1_non_validees( + etud: Identite, formation: Formation, parcour: ApcParcours +) -> list[UniteEns]: + """Vrai si cet étudiant a validé toutes ses UEs de S1 et S2, dans son parcours""" + # Les UEs avec décisions, dans les S1 ou S2 d'une formation de même code: + validations = ( + ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) + .filter(ScolarFormSemestreValidation.ue_id != None) + .join(UniteEns) + .filter(db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2)) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + ) + codes_validations_by_ue = collections.defaultdict(list) + for v in validations: + codes_validations_by_ue[v.ue_id].append(v.code) + + # Les UEs du parcours en S1 et S2: + ues = formation.query_ues_parcour(parcour).filter( + db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2) + ) + # Liste triée des ues non validées + return sorted( + [ + ue + for ue in ues + if any( + (not code_ue_validant(code) for code in codes_validations_by_ue[ue.id]) + ) + ], + key=attrgetter("numero", "acronyme"), + ) + + def formsemestre_warning_apc_setup( formsemestre: FormSemestre, res: ResultatsSemestreBUT ) -> str: diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 227ead81c..c1ded7b88 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -69,6 +69,7 @@ from flask import flash, g, url_for from app import db from app import log +from app.but import cursus_but from app.but.cursus_but import EtudCursusBUT from app.comp.res_but import ResultatsSemestreBUT from app.comp import res_sem @@ -363,15 +364,33 @@ class DecisionsProposeesAnnee(DecisionsProposees): "Vrai si plus de la moitié des RCUE validables" self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0) "Vrai si peut passer dans l'année BUT suivante: plus de la moitié validables et tous > 8" - # XXX TODO ajouter condition pour passage en S5 + explanation = "" + # Cas particulier du passage en BUT 3: nécessité d’avoir validé toutes les UEs du BUT 1. + if self.passage_de_droit and self.annee_but == 2: + inscription = formsemestre.etuds_inscriptions.get(etud.id) + if inscription: + ues_but1_non_validees = cursus_but.etud_ues_de_but1_non_validees( + etud, formation, inscription.parcour + ) + self.passage_de_droit = not ues_but1_non_validees + explanation += ( + f"""UEs de BUT1 non validées: { + ', '.join(ue.acronyme for ue in ues_but1_non_validees) + }. """ + if ues_but1_non_validees + else "" + ) + else: + # pas inscrit dans le semestre courant ??? + self.passage_de_droit = False - # Enfin calcule les codes des UE: + # Enfin calcule les codes des UEs: for dec_ue in self.decisions_ues.values(): dec_ue.compute_codes() # Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR plural = self.nb_validables > 1 - expl_rcues = f"""{self.nb_validables} niveau{"x" if plural else ""} validable{ + explanation += f"""{self.nb_validables} niveau{"x" if plural else ""} validable{ "s" if plural else ""} sur {self.nb_competences}""" if self.admis: self.codes = [sco_codes.ADM] + self.codes @@ -390,7 +409,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): sco_codes.ABL, sco_codes.EXCLU, ] - expl_rcues = "" + explanation = "" elif self.passage_de_droit: self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes elif self.valide_moitie_rcue: # mais au moins 1 rcue insuffisante @@ -400,7 +419,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): sco_codes.PAS1NCI, sco_codes.ADJ, ] + self.codes - expl_rcues += f" et {self.nb_rcues_under_8} < 8" + explanation += f" et {self.nb_rcues_under_8} < 8" else: self.codes = [ sco_codes.RED, @@ -409,7 +428,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): sco_codes.ADJ, sco_codes.PASD, # voir #488 (discutable, conventions locales) ] + self.codes - expl_rcues += f""" et {self.nb_rcues_under_8} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8""" + explanation += f""" et {self.nb_rcues_under_8} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8""" # Si l'un des semestres est extérieur, propose ADM if ( @@ -419,7 +438,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): # Si validée par niveau supérieur: if self.code_valide == sco_codes.ADSUP: self.codes.insert(0, sco_codes.ADSUP) - self.explanation = f"
{expl_rcues}
" + self.explanation = f"
{explanation}
" messages = self.descr_pb_coherence() if messages: self.explanation += ( @@ -1261,6 +1280,8 @@ class DecisionsProposeesRCUE(DecisionsProposees): validation_rcue = validations_rcue[0] validation_rcue.code = sco_codes.ADSUP validation_rcue.date = datetime.now() + db.session.add(validation_rcue) + db.session.commit() log(f"updating {validation_rcue}") if validation_rcue.formsemestre_id is not None: sco_cache.invalidate_formsemestre( @@ -1271,9 +1292,9 @@ class DecisionsProposeesRCUE(DecisionsProposees): validation_rcue = ApcValidationRCUE( etudid=self.etud.id, ue1_id=ue1.id, ue2_id=ue2.id, code=sco_codes.ADSUP ) + db.session.add(validation_rcue) + db.session.commit() log(f"recording {validation_rcue}") - db.session.add(validation_rcue) - db.session.commit() self.valide_annee_inferieure() def valide_annee_inferieure(self) -> None: diff --git a/app/models/validations.py b/app/models/validations.py index cc5565187..229d15ad5 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -11,7 +11,7 @@ from app.models.events import Scolog class ScolarFormSemestreValidation(db.Model): - """Décisions de jury""" + """Décisions de jury (sur semestre ou UEs)""" __tablename__ = "scolar_formsemestre_validation" # Assure unicité de la décision: diff --git a/tests/ressources/yaml/cursus_but_gccd_cy.yaml b/tests/ressources/yaml/cursus_but_gccd_cy.yaml index 668951d2c..8852a3643 100644 --- a/tests/ressources/yaml/cursus_but_gccd_cy.yaml +++ b/tests/ressources/yaml/cursus_but_gccd_cy.yaml @@ -255,3 +255,26 @@ Etudiants: parcours: TP notes_modules: "R4.01": 14 # toutes UE + + D: # Etudiant arrive en S4 avec une UE manquante en S1 + prenom: Étudiant_TP_malaise + civilite: M + formsemestres: + S1: + parcours: TP + notes_modules: + "R1.01": 11 # toutes UEs + "SAÉ 1-2": 8 # plombe l'UE 2 + S2: + parcours: TP + notes_modules: + "R2.01": 11 # toutes UEs + S3: + parcours: TP + notes_modules: + "R3.01": 12 # toutes UEs + S4: + parcours: TP + notes_modules: + "R4.01": 14 # toutes UE + "R4.04": 6 # plombe l'UE1 diff --git a/tests/unit/yaml_setup.py b/tests/unit/yaml_setup.py index f24b92b79..35b016a2f 100644 --- a/tests/unit/yaml_setup.py +++ b/tests/unit/yaml_setup.py @@ -146,7 +146,7 @@ def create_formsemestre( return formsemestre -def create_evaluations(formsemestre: FormSemestre): +def create_evaluations(formsemestre: FormSemestre, publish_incomplete=True): """Crée une évaluation dans chaque module du semestre""" for modimpl in formsemestre.modimpls: evaluation = Evaluation( @@ -156,6 +156,7 @@ def create_evaluations(formsemestre: FormSemestre): coefficient=1.0, note_max=20.0, numero=1, + publish_incomplete=publish_incomplete, ) db.session.add(evaluation) From f9b45392316c5fbd6f1a1242c927c6c22fd3df97 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 16 Jun 2023 07:54:28 +0200 Subject: [PATCH 05/17] =?UTF-8?q?Fix:=20mise=20=C3=A0=20jour=20base=20post?= =?UTF-8?q?gres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/versions/d84bc592584e_extension_unaccent.py | 3 ++- sco_version.py | 2 +- tools/debian/postinst | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/migrations/versions/d84bc592584e_extension_unaccent.py b/migrations/versions/d84bc592584e_extension_unaccent.py index 041da64ce..216ee4eee 100644 --- a/migrations/versions/d84bc592584e_extension_unaccent.py +++ b/migrations/versions/d84bc592584e_extension_unaccent.py @@ -25,7 +25,8 @@ def upgrade(): bind = op.get_bind() session = Session(bind=bind) # Ajout extension pour recherches sans accents: - session.execute(sa.text("""CREATE EXTENSION IF NOT EXISTS "unaccent";""")) + # erreur: doit s'executer en superuser + # session.execute(sa.text("""CREATE EXTENSION IF NOT EXISTS "unaccent";""")) # Clé étrangère sur identite session.execute( diff --git a/sco_version.py b/sco_version.py index 8d1008831..cb65163a2 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.84" +SCOVERSION = "9.4.85" SCONAME = "ScoDoc" diff --git a/tools/debian/postinst b/tools/debian/postinst index a5fcc1b18..dad8205ee 100755 --- a/tools/debian/postinst +++ b/tools/debian/postinst @@ -97,13 +97,15 @@ fi init_postgres_user # ------------ BASE DE DONNEES -# gérées avec Flask-Migrate (Alembic/SQLAlchemy) # Si la base SCODOC existe, tente de la mettre à jour # (Ne gère pas les bases DEV et TEST) n=$(su -c "psql -l | grep -c -E '^[[:blank:]]*SCODOC[[:blank:]]*\|'" "$SCODOC_USER") if [ "$n" == 1 ] then echo "Upgrading existing SCODOC database..." + # Ajout extension unaccent (postgres superuser, ajout sur base SCODOC) + (cd /tmp; echo 'CREATE EXTENSION IF NOT EXISTS "unaccent";' | su -c psql postgres) + # Migrations gérées avec Flask-Migrate (Alembic/SQLAlchemy) # utilise les scripts dans migrations/version/ # pour mettre à jour notre base (en tant qu'utilisateur scodoc) export FLASK_ENV="production" From 84d40091a8bfaa5f226c542c98d1b08b2bef0d8d Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 17 Jun 2023 14:56:04 +0200 Subject: [PATCH 06/17] Fix: ordre des RCUE sur les bulletins --- app/models/but_validations.py | 8 ++++++-- sco_version.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/models/but_validations.py b/app/models/but_validations.py index d9b0e7e2d..c2be058b3 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -360,8 +360,12 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict: # --- RCUEs: seulement sur semestres pairs XXX à améliorer if formsemestre.semestre_id % 2 == 0: # validations émises depuis ce formsemestre: - validations_rcues = ApcValidationRCUE.query.filter_by( - etudid=etud.id, formsemestre_id=formsemestre.id + validations_rcues = ( + ApcValidationRCUE.query.filter_by( + etudid=etud.id, formsemestre_id=formsemestre.id + ) + .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id) + .order_by(UniteEns.numero, UniteEns.acronyme) ) decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues] titres_rcues = [] diff --git a/sco_version.py b/sco_version.py index cb65163a2..02d22d18d 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.85" +SCOVERSION = "9.4.86" SCONAME = "ScoDoc" From 756c46df0bcaffa6869cdaaee52319b371ec2cbb Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 18 Jun 2023 09:37:13 +0200 Subject: [PATCH 07/17] =?UTF-8?q?Suppressions=20de=20d=C3=A9cisions=20de?= =?UTF-8?q?=20jury?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 9 +- app/but/jury_but_view.py | 2 +- app/comp/jury.py | 90 ++++++++++++++++++- app/models/but_validations.py | 9 +- app/models/formations.py | 2 +- app/models/formsemestre.py | 10 ++- app/models/validations.py | 33 ++++++- app/scodoc/sco_cache.py | 13 +++ app/scodoc/sco_edit_ue.py | 9 +- app/scodoc/sco_recapcomplet.py | 4 +- app/scodoc/sco_semset.py | 7 +- app/static/css/cursus_but.css | 7 +- app/templates/bul_head.j2 | 8 +- app/templates/but/cursus_etud.j2 | 6 +- app/templates/but/parcour_formation.j2 | 4 +- .../jury/erase_decisions_annee_formation.j2 | 41 +++++++++ app/views/notes.py | 71 ++++++++++++--- 17 files changed, 276 insertions(+), 49 deletions(-) create mode 100644 app/templates/jury/erase_decisions_annee_formation.j2 diff --git a/app/but/jury_but.py b/app/but/jury_but.py index c1ded7b88..af9b46731 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -75,11 +75,9 @@ 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 Scolog, ScolarAutorisationInscription from app.models.but_validations import ( @@ -89,7 +87,7 @@ from app.models.but_validations import ( ) from app.models.etudiants import Identite from app.models.formations import Formation -from app.models.formsemestre import FormSemestre, FormSemestreInscription +from app.models.formsemestre import FormSemestre from app.models.ues import UniteEns from app.models.validations import ScolarFormSemestreValidation from app.scodoc import sco_cache @@ -473,7 +471,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): scodoc_dept=g.scodoc_dept, semestre_idx=formsemestre.semestre_id, formation_id=formsemestre.formation.id)}"> - {formsemestre.formation.to_html()} ({ + {formsemestre.formation.html()} ({ formsemestre.formation.id}) @@ -902,7 +900,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): pour cette année: décisions d'UE, de RCUE, d'année, et autorisations d'inscription émises. Efface même si étudiant DEM ou DEF. - Si à cheval, n'efface que pour le semestre d'origine du deca. + Si à cheval ou only_one_sem, n'efface que les décisions UE et les + autorisations de passage du semestre d'origine du deca. (commite la session.) """ if only_one_sem or self.a_cheval: diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 0a20e9334..a61f1f14b 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -246,7 +246,7 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str: scoplement = ( f"""
{ - dec_rcue.validation.to_html() + dec_rcue.validation.html() }
""" if dec_rcue.validation else "" diff --git a/app/comp/jury.py b/app/comp/jury.py index cee32ffdd..1c43158da 100644 --- a/app/comp/jury.py +++ b/app/comp/jury.py @@ -10,8 +10,17 @@ import pandas as pd import sqlalchemy as sa from app import db -from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns from app.comp.res_cache import ResultatsCache +from app.models import ( + ApcValidationAnnee, + ApcValidationRCUE, + Formation, + FormSemestre, + Identite, + ScolarAutorisationInscription, + ScolarFormSemestreValidation, + UniteEns, +) from app.scodoc import sco_cache from app.scodoc import codes_cursus @@ -81,7 +90,7 @@ class ValidationsSemestre(ResultatsCache): # UEs: { etudid : { ue_id : {"code":, "ects":, "event_date":} }} decisions_jury_ues = {} - # Parcours les décisions d'UE: + # Parcoure les décisions d'UE: for decision in ( decisions_jury_q.filter(db.text("ue_id is not NULL")) .join(UniteEns) @@ -172,3 +181,80 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame with db.engine.begin() as connection: df = pd.read_sql_query(query, connection, params=params, index_col="etudid") return df + + +def erase_decisions_annee_formation( + etud: Identite, formation: Formation, annee: int, delete=False +) -> list: + """Efface toutes les décisions de jury de l'étudiant dans les formations de même code + que celle donnée pour cette année de la formation: + UEs, RCUEs de l'année BUT, année BUT, passage vers l'année suivante. + Ne considère pas l'origine de la décision. + annee: entier, 1, 2, 3, ... + Si delete est faux, renvoie la liste des validations qu'il faudrait effacer, sans y toucher. + """ + sem1, sem2 = annee * 2 - 1, annee * 2 + # UEs + validations = ( + ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) + .join(UniteEns) + .filter(db.or_(UniteEns.semestre_idx == sem1, UniteEns.semestre_idx == sem2)) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + .order_by( + UniteEns.acronyme, UniteEns.numero + ) # acronyme d'abord car 2 semestres + .all() + ) + # RCUEs (a priori inutile de matcher sur l'ue2_id) + validations += ( + ApcValidationRCUE.query.filter_by(etudid=etud.id) + .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id) + .filter_by(semestre_idx=sem1) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + .order_by(UniteEns.acronyme, UniteEns.numero) + .all() + ) + # Validation de semestres classiques + validations += ( + ScolarFormSemestreValidation.query.filter_by(etudid=etud.id, ue_id=None) + .join( + FormSemestre, + FormSemestre.id == ScolarFormSemestreValidation.formsemestre_id, + ) + .filter( + db.or_(FormSemestre.semestre_id == sem1, FormSemestre.semestre_id == sem2) + ) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + .all() + ) + # Année BUT + validations += ( + ApcValidationAnnee.query.filter_by(etudid=etud.id, ordre=annee) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + .all() + ) + # Autorisations vers les semestres suivants ceux de l'année: + validations += ( + ScolarAutorisationInscription.query.filter_by( + etudid=etud.id, formation_code=formation.formation_code + ) + .filter( + db.or_( + ScolarAutorisationInscription.semestre_id == sem1 + 1, + ScolarAutorisationInscription.semestre_id == sem2 + 1, + ) + ) + .all() + ) + + if delete: + for validation in validations: + db.session.delete(validation) + db.session.commit() + sco_cache.invalidate_formsemestre_etud(etud) + return [] + return validations diff --git a/app/models/but_validations.py b/app/models/but_validations.py index c2be058b3..778164765 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -66,7 +66,7 @@ class ApcValidationRCUE(db.Model): return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: { self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}""" - def to_html(self) -> str: + def html(self) -> str: "description en HTML" return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {self.code} @@ -348,6 +348,13 @@ class ApcValidationAnnee(db.Model): "ordre": self.ordre, } + def html(self) -> str: + "Affichage html" + return f"""Validation année BUT{self.ordre} émise par + {self.formsemestre.html_link_status() if self.formsemestre else "-"} + le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")} + """ + def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict: """ diff --git a/app/models/formations.py b/app/models/formations.py index e98d66f7b..fb7529e32 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -60,7 +60,7 @@ class Formation(db.Model): return f"""<{self.__class__.__name__}(id={self.id}, dept_id={ self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>""" - def to_html(self) -> str: + def html(self) -> str: "titre complet pour affichage" return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}""" diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 516d8fc51..efbceb74b 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -16,7 +16,7 @@ from operator import attrgetter from flask_login import current_user -from flask import flash, g +from flask import flash, g, url_for from sqlalchemy.sql import text import app.scodoc.sco_utils as scu @@ -163,6 +163,14 @@ class FormSemestre(db.Model): def __repr__(self): return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>" + def html_link_status(self) -> str: + "html link to status page" + return f"""{self.titre_mois()} + """ + @classmethod def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre": """ "FormSemestre ou 404, cherche uniquement dans le département courant""" diff --git a/app/models/validations.py b/app/models/validations.py index 229d15ad5..9a938b6c5 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -59,13 +59,16 @@ class ScolarFormSemestreValidation(db.Model): ) def __repr__(self): - return f"{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})" + return f"""{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={ + self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})""" def __str__(self): if self.ue_id: # Note: si l'objet vient d'être créé, ue_id peut exister mais pas ue ! - return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id}: {self.code}""" - return f"""décision sur semestre {self.formsemestre.titre_mois()} du {self.event_date.strftime("%d/%m/%Y")}""" + return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id + }: {self.code}""" + return f"""décision sur semestre {self.formsemestre.titre_mois()} du { + self.event_date.strftime("%d/%m/%Y")}""" def to_dict(self) -> dict: "as a dict" @@ -73,6 +76,20 @@ class ScolarFormSemestreValidation(db.Model): d.pop("_sa_instance_state", None) return d + def html(self) -> str: + "Affichage html" + if self.ue_id is not None: + return f"""Validation de l'UE {self.ue.acronyme} + ({self.code} + le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}) + """ + else: + return f"""Validation du semestre S{ + self.formsemestre.semestre_id if self.formsemestre else "?"} + ({self.code} + le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}) + """ + class ScolarAutorisationInscription(db.Model): """Autorisation d'inscription dans un semestre""" @@ -93,6 +110,7 @@ class ScolarAutorisationInscription(db.Model): db.Integer, db.ForeignKey("notes_formsemestre.id"), ) + origin_formsemestre = db.relationship("FormSemestre", lazy="select", uselist=False) def __repr__(self) -> str: return f"""{self.__class__.__name__}(id={self.id}, etudid={ @@ -104,6 +122,15 @@ class ScolarAutorisationInscription(db.Model): d.pop("_sa_instance_state", None) return d + def html(self) -> str: + "Affichage html" + return f"""Autorisation de passage vers S{self.semestre_id} émise par + {self.origin_formsemestre.html_link_status() + if self.origin_formsemestre + else "-"} + le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")} + """ + @classmethod def autorise_etud( cls, diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index 0b9d27a83..36b8db30b 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -315,6 +315,19 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa SemBulletinsPDFCache.invalidate_sems(formsemestre_ids) +def invalidate_formsemestre_etud(etud: "Identite"): + """Invalide tous les formsemestres auxquels l'étudiant est inscrit""" + from app.models import FormSemestre, FormSemestreInscription + + inscriptions = ( + FormSemestreInscription.query.filter_by(etudid=etud.id) + .join(FormSemestre) + .filter_by(dept_id=g.scodoc_dept_id) + ) + for inscription in inscriptions: + invalidate_formsemestre(inscription.formsemestre_id) + + class DeferredSemCacheManager: """Contexte pour effectuer des opérations indépendantes dans la même requete qui invalident le cache. Par exemple, quand on inscrit diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index f41804f13..ba6cb918e 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -757,7 +757,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list ], page_title=f"Programme {formation.acronyme} v{formation.version}", ), - f"""

{formation.to_html()} {lockicon} + f"""

{formation.html()} {lockicon}

""", ] @@ -1010,12 +1010,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);

    """ ) for formsemestre in formsemestres: - H.append( - f"""
  • {formsemestre.titre_mois()}""" - ) + H.append(f"""
  • {formsemestre.html_link_status()}""") if not formsemestre.etat: H.append(" [verrouillé]") else: diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 549b375d1..63a0d4e25 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -142,7 +142,7 @@ def formsemestre_recapcomplet( H.append( ' Bulletin - {{formsemestre.titre_mois() - }} + + {{formsemestre.html_link_status() | safe}} +
    établi le {{time.strftime("%d/%m/%Y à %Hh%M")}} (notes sur 20) diff --git a/app/templates/but/cursus_etud.j2 b/app/templates/but/cursus_etud.j2 index 571909683..4baaa96f1 100644 --- a/app/templates/but/cursus_etud.j2 +++ b/app/templates/but/cursus_etud.j2 @@ -15,14 +15,16 @@
    {{validation.code}}
    {{validation.ue1.acronyme}} - {{validation.ue2.acronyme}}
    -
    Jury de {{validation.formsemestre.titre_annee()}}
    +
    Jury de {{validation.formsemestre.titre_annee() if validation.formsemestre else "-"}}
    enregistré le {{ validation.date.strftime("%d/%m/%Y à %H:%M") }}
    {% else %} - - +
    +
    -
    +
    {%endif%} {% endfor %} diff --git a/app/templates/but/parcour_formation.j2 b/app/templates/but/parcour_formation.j2 index f128c0524..84aa11f02 100644 --- a/app/templates/but/parcour_formation.j2 +++ b/app/templates/but/parcour_formation.j2 @@ -44,7 +44,7 @@ {%- endmacro %} {% block app_content %} -

    {{formation.to_html()}}

    +

    {{formation.html()}}

    {# Liens vers les différents parcours #}
    @@ -127,7 +127,7 @@ Choisissez un parcours... d'associer à chaque semestre d'un niveau de compétence une UE de la formation {{formation.to_html()}} + }}">{{formation.html()}} .

    Le symbole TC désigne un niveau du tronc commun diff --git a/app/templates/jury/erase_decisions_annee_formation.j2 b/app/templates/jury/erase_decisions_annee_formation.j2 new file mode 100644 index 000000000..5298437a5 --- /dev/null +++ b/app/templates/jury/erase_decisions_annee_formation.j2 @@ -0,0 +1,41 @@ +{% extends 'base.j2' %} + +{% block app_content %} + +{% if not validations %} +

    Aucune validation de jury enregistrée pour {{etud.nom_disp()}} sur +l'année {{annee}} +de la formation {{ formation.html() }} +

    + + +{% else %} + +

    Effacer les décisions de jury pour l'année {{annee}} de {{etud.nom_disp()}} ?

    + +

    Affectera toutes les décisions concernant l'année {{annee}} de la formation, +quelle que soit leur origine.

    + +

    Les décisions concernées sont:

    +
      + {% for validation in validations %} +
    • {{ validation.html() | safe}} +
    • + {% endfor %} +
    +
    +
    + + {% if cancel_url %} + + {% endif %} +
    +
    +{% endif %} + + + +{% endblock %} \ No newline at end of file diff --git a/app/views/notes.py b/app/views/notes.py index 70990ac60..e29a93c91 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -48,9 +48,9 @@ from app.but.forms import jury_but_forms from app.but import jury_but_pv from app.but import jury_but_view -from app.comp import res_sem +from app.comp import jury, res_sem from app.comp.res_compat import NotesTableCompat -from app.models import ScolarAutorisationInscription, ScolarNews, Scolog +from app.models import Formation, ScolarAutorisationInscription, ScolarNews, Scolog from app.models.but_refcomp import ApcNiveau, ApcParcours from app.models.config import ScoDocSiteConfig from app.models.etudiants import Identite @@ -2494,7 +2494,19 @@ def formsemestre_validation_but( erase_span = f"""effacer décisions""" + etudid=deca.etud.id)}" class="stdlink" + title="efface décisions issues des jurys de cette année" + >effacer décisions + + effacer toutes ses décisions de BUT{deca.annee_but} + """ H.append( f"""
    @@ -2815,15 +2827,15 @@ def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None): ) @scodoc @permission_required(Permission.ScoView) -def formsemestre_jury_but_erase( - formsemestre_id: int, etudid: int = None, only_one_sem=False -): +def formsemestre_jury_but_erase(formsemestre_id: int, etudid: int = None): """Supprime la décision de jury BUT pour cette année. - Si only_one_sem, n'efface que pour le formsemestre indiqué, pas les deux de l'année. Si l'étudiant n'est pas spécifié, efface les décisions de tous les inscrits. + Si only_one_sem, n'efface que pour le formsemestre indiqué, pas les deux de l'année. """ only_one_sem = int(request.args.get("only_one_sem") or False) - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + formsemestre: FormSemestre = FormSemestre.query.filter_by( + id=formsemestre_id, dept_id=g.scodoc_dept_id + ).first_or_404() if not formsemestre.can_edit_jury(): raise ScoPermissionDenied( dest_url=url_for( @@ -2881,14 +2893,53 @@ def formsemestre_jury_but_erase( if only_one_sem else """Les validations de toutes les UE, RCUE (compétences) et année issues de cette année scolaire seront effacées. - Les décisions des années scolaires précédentes ne seront pas modifiées. """ ) - + """
    Cette opération est irréversible !
    """, + + """ +

    Les décisions des années scolaires précédentes ne seront pas modifiées.

    +
    Cette opération est irréversible !
    + """, cancel_url=dest_url, ) +@bp.route( + "/erase_decisions_annee_formation///", + methods=["GET", "POST"], +) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int): + """Efface toute les décisions d'une année pour cet étudiant""" + etud: Identite = Identite.query.get_or_404(etudid) + formation: Formation = Formation.query.filter_by( + id=formation_id, dept_id=g.scodoc_dept_id + ).first_or_404() + if request.method == "POST": + jury.erase_decisions_annee_formation(etud, formation, annee, delete=True) + flash("Décisions de jury effacées") + return redirect( + url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etud.id, + ) + ) + validations = jury.erase_decisions_annee_formation(etud, formation, annee) + return render_template( + "jury/erase_decisions_annee_formation.j2", + annee=annee, + cancel_url=url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id + ), + etud=etud, + formation=formation, + validations=validations, + sco=ScoData(), + title=f"Effacer décisions de jury {etud.nom} - année {annee}", + ) + + sco_publish( "/formsemestre_lettres_individuelles", sco_pv_forms.formsemestre_lettres_individuelles, From de23302b3e5f138630736270168bccf8af43b730 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 18 Jun 2023 21:20:02 +0200 Subject: [PATCH 08/17] =?UTF-8?q?Jury=20BUT:=20ajout=20colonne=20d=C3=A9ci?= =?UTF-8?q?sion=20ann=C3=A9e=20sur=20table=20r=C3=A9cap.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/comp/res_but.py | 48 +++++++++++++++++++++++++++++++--- app/scodoc/sco_recapcomplet.py | 1 + app/tables/jury_recap.py | 14 +++++++++- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/app/comp/res_but.py b/app/comp/res_but.py index a91b1dbbc..2c60f24a0 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -10,17 +10,17 @@ import time import numpy as np import pandas as pd -from app import log +from app import db, log from app.comp import moy_ue, moy_sem, inscr_mod from app.comp.res_compat import NotesTableCompat from app.comp.bonus_spo import BonusSport -from app.models import ScoDocSiteConfig +from app.models import Formation, FormSemestreInscription, ScoDocSiteConfig from app.models.moduleimpls import ModuleImpl from app.models.but_refcomp import ApcParcours, ApcNiveau from app.models.ues import DispenseUE, UniteEns from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.scodoc import sco_preferences -from app.scodoc.codes_cursus import UE_SPORT +from app.scodoc.codes_cursus import BUT_CODES_ORDERED, UE_SPORT from app.scodoc.sco_utils import ModuleType @@ -44,7 +44,8 @@ class ResultatsSemestreBUT(NotesTableCompat): """Parcours de chaque étudiant { etudid : parcour_id }""" self.ues_ids_by_parcour: dict[set[int]] = {} """{ parcour_id : set }, ue_id de chaque parcours""" - + self.validations_annee: dict[int, ApcValidationAnnee] = {} + """chargé par get_validations_annee: jury annuel BUT""" if not self.load_cached(): t0 = time.time() self.compute() @@ -321,3 +322,42 @@ class ResultatsSemestreBUT(NotesTableCompat): formsemestre_id=self.formsemestre.id, etudid=etudid ).count() ) + + def get_validations_annee(self) -> dict[int, ApcValidationAnnee]: + """Les validations des étudiants de ce semestre + pour l'année BUT d'une formation compatible avec celle de ce semestre. + Attention: + 1) la validation ne provient pas nécessairement de ce semestre + (redoublants, pair/impair, extérieurs). + 2) l'étudiant a pu démissionner ou défaillir. + 3) S'il y a plusieurs validations pour le même étudiant, prend la "meilleure". + + Mémorise le résultat (dans l'instance, pas en cache: TODO voir au profiler) + """ + if self.validations_annee: + return self.validations_annee + annee_but = (self.formsemestre.semestre_id + 1) // 2 + validations = ( + ApcValidationAnnee.query.filter_by(ordre=annee_but) + .join(Formation) + .filter_by(formation_code=self.formsemestre.formation.formation_code) + .join( + FormSemestreInscription, + db.and_( + FormSemestreInscription.etudid == ApcValidationAnnee.etudid, + FormSemestreInscription.formsemestre_id == self.formsemestre.id, + ), + ) + ) + validation_by_etud = {} + for validation in validations: + if validation.etudid in validation_by_etud: + # keep the "best" + if BUT_CODES_ORDERED.get(validation.code, 0) > BUT_CODES_ORDERED.get( + validation_by_etud[validation.etudid].code, 0 + ): + validation_by_etud[validation.etudid] = validation + else: + validation_by_etud[validation.etudid] = validation + self.validations_annee = validation_by_etud + return self.validations_annee diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 63a0d4e25..9ebe4e87d 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -251,6 +251,7 @@ def formsemestre_recapcomplet(
    ~
    valeur manquante
    =
    UE dispensée
    nan
    valeur non disponible
    +
    📍
    code jury non enregistré
    """ diff --git a/app/tables/jury_recap.py b/app/tables/jury_recap.py index 5d9e5b885..8d02065cf 100644 --- a/app/tables/jury_recap.py +++ b/app/tables/jury_recap.py @@ -81,11 +81,14 @@ class TableJury(TableRecap): def add_jury(self): """Ajoute la colonne code jury et le lien. - Le code jury est celui du semestre: cette colonne n'est montrée + - Le code jury est celui du semestre: cette colonne n'est montrée que pour les formations classiques, ce code n'est pas utilisé en BUT. + - En BUT, on donne la décision de jury annuelle. """ res = self.res autorisations = res.get_autorisations_inscription() + if res.is_apc: + validations_annee = res.get_validations_annee() for row in self.rows: etud = row.etud if not res.is_apc: @@ -115,6 +118,15 @@ class TableJury(TableRecap): group="jury_code_sem", classes=["recorded_code"], ) + if res.is_apc: # BUT + validation_annee = validations_annee.get(etud.id, None) + row.add_cell( + "decision_annuelle", + "Année", + validation_annee.code if validation_annee else "", + group="jury_code_sem", + classes=["recorded_code"], + ) # Lien saisie ou visu jury a_saisir = (not res.validations) or (not res.validations.has_decision(etud)) row.add_cell( From fdfffb70becf46dbcd214050f2176577196df124 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 18 Jun 2023 21:42:14 +0200 Subject: [PATCH 09/17] Table jury BUT: ajout explication sur col RCUEs --- app/static/js/table_recap.js | 578 ++++++++++++++++++----------------- 1 file changed, 302 insertions(+), 276 deletions(-) diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js index 56d43e3d9..11a5db4d4 100644 --- a/app/static/js/table_recap.js +++ b/app/static/js/table_recap.js @@ -1,285 +1,311 @@ // Tableau recap notes $(function () { - $(function () { - if ($('table.table_recap').length == 0) { return; } + $(function () { + if ($("table.table_recap").length == 0) { + return; + } - let hidden_colums = [ - "etud_codes", "identite_detail", - "partition_aux", "partition_rangs", "admission", - "col_empty" - ]; - // Etat (tri des colonnes) de la table: + let hidden_colums = [ + "etud_codes", + "identite_detail", + "partition_aux", + "partition_rangs", + "admission", + "col_empty", + ]; + // Etat (tri des colonnes) de la table: - const url = new URL(document.URL); - const formsemestre_id = url.searchParams.get("formsemestre_id"); - const order_info_key = JSON.stringify([url.pathname, formsemestre_id]); - const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]); - const noms_key = JSON.stringify(["noms", url.origin, formsemestre_id]); - let order_info; - if (formsemestre_id) { - const x = localStorage.getItem(order_info_key); - if (x) { - try { - order_info = JSON.parse(x); - } catch (error) { - console.error(error); - } - } - } - - // Les colonnes visibles sont mémorisées, il faut initialiser l'état des boutons - function update_buttons_labels(dt) { - // chaque bouton controle une classe stockée dans le data-group du span - document.querySelectorAll("button.dt-button").forEach(but => { - let g_span = but.querySelector("span > span"); - if (g_span) { - let group = g_span.dataset["group"]; - if (group) { - // si le group (= la 1ere col.) est visible, but_on - if (dt.columns("." + group).visible()[0]) { - but.classList.add("but_on"); - but.classList.remove("but_off"); - } else { - but.classList.add("but_off"); - but.classList.remove("but_on"); - } - } - } - }); - } - - // Changement visibilité groupes colonnes (boutons) - function toggle_col_but_visibility(e, dt, node, config) { - let group = node.children()[0].firstChild.dataset.group; - toggle_col_group_visibility(dt, group, node.hasClass("but_on")); - } - function toggle_col_ident_visibility(e, dt, node, config) { - let onoff = node.hasClass("but_on"); - toggle_col_group_visibility(dt, "identite_detail", onoff); - toggle_col_group_visibility(dt, "identite_court", !onoff); - } - function toggle_col_ressources_visibility(e, dt, node, config) { - let onoff = node.hasClass("but_on"); - toggle_col_group_visibility(dt, "col_res", onoff); - toggle_col_group_visibility(dt, "col_ue_bonus", onoff); - toggle_col_group_visibility(dt, "col_malus", onoff); - } - function toggle_col_group_visibility(dt, group, onoff) { - if (onoff) { - dt.columns('.' + group).visible(false); - } else { - dt.columns('.' + group).visible(true); - } - update_buttons_labels(dt); - } - // Definition des boutons au dessus de la table: - let buttons = [ - { - extend: 'copyHtml5', - text: 'Copier', - exportOptions: { orthogonal: 'export' } - }, - { - extend: 'excelHtml5', - // footer: true, // ne fonctionne pas ? - exportOptions: { orthogonal: 'export' }, - title: document.querySelector('table.table_recap').dataset.filename - }, - { - // force affichage de toutes les colonnes - text: '', - action: function (e, dt, node, config) { - dt.columns().visible(true); - update_buttons_labels(dt); - } - }, - { - text: '🔄', - action: function (e, dt, node, config) { - localStorage.clear(); - console.log("cleared localStorage"); - location.reload(); - } - }, - { - text: 'Civilité', - action: toggle_col_ident_visibility, - }, - { - text: 'Groupes', - action: toggle_col_but_visibility, - }, - { - text: 'Rg', - action: toggle_col_but_visibility, - }, - ]; // fin des boutons communs à toutes les tables recap - - if ($('table.table_recap').hasClass("jury")) { - // Table JURY: - // avec ou sans codes enregistrés - buttons.push( - { - text: 'Codes jury', - action: toggle_col_but_visibility, - }); - if ($('table.table_recap').hasClass("apc")) { - // Boutons spécifiques à la table JURY BUT - buttons.push( - { - text: 'Compétences', - action: toggle_col_but_visibility, - }); - buttons.push( - { - text: 'RCUEs', - action: toggle_col_but_visibility, - }); - } - } else { - // BOUTONS SPECIFIQUES A LA TABLE RECAP NON JURY - buttons.push( - $('table.table_recap').hasClass("apc") ? - { - text: 'Ressources', - action: toggle_col_ressources_visibility, - } : { - name: "toggle_mod", - text: "Cacher les modules", - action: function (e, dt, node, config) { - let onoff = node.hasClass("but_on"); - toggle_col_group_visibility(dt, "col_mod:not(.col_empty)", onoff); - toggle_col_group_visibility(dt, "col_ue_bonus", onoff); - toggle_col_group_visibility(dt, "col_malus", onoff); - } - } - ); - if ($('table.table_recap').hasClass("apc")) { - buttons.push({ - text: 'SAÉs', - action: toggle_col_but_visibility, - }); - } - // S'il y a des colonnes vides: - if ($('table.table_recap td.col_empty').length > 0) { - buttons.push({ // modules vides - text: 'Vides', - action: toggle_col_but_visibility, - }); - } - // Boutons admission (pas en jury) - if (!$('table.table_recap').hasClass("jury")) { - buttons.push( - { - text: 'Admission', - action: toggle_col_but_visibility, - } - ); - } - } - // Boutons évaluations (si présentes) - if ($('table.table_recap').hasClass("with_evaluations")) { - buttons.push( - { - text: 'Évaluations', - action: toggle_col_but_visibility, - } - ); - } - - // ------------- LA TABLE --------- + const url = new URL(document.URL); + const formsemestre_id = url.searchParams.get("formsemestre_id"); + const order_info_key = JSON.stringify([url.pathname, formsemestre_id]); + const etudids_key = JSON.stringify([ + "etudids", + url.origin, + formsemestre_id, + ]); + const noms_key = JSON.stringify(["noms", url.origin, formsemestre_id]); + let order_info; + if (formsemestre_id) { + const x = localStorage.getItem(order_info_key); + if (x) { try { - let table = $('table.table_recap').DataTable( - { - paging: false, - searching: true, - info: false, - autoWidth: false, - fixedHeader: { - header: true, - footer: false - }, - orderCellsTop: true, // cellules ligne 1 pour tri - aaSorting: [], // Prevent initial sorting - colReorder: true, - stateSave: true, // enregistre état de la table (tris, ...) - "columnDefs": [ - { - // cache les codes, le détail de l'identité, les groupes, les colonnes admission et les vides - targets: hidden_colums, - visible: false, - }, - { - // Elimine les 0 à gauche pour les exports excel et les "copy" - targets: ["col_mod", "col_moy_gen", "col_moy_ue", "col_res", "col_sae", "evaluation", "col_rcue"], - render: function (data, type, row) { - return type === 'export' ? data.replace(/0(\d\..*)/, '$1') : data; - } - }, - { - // Elimine les "+"" pour les exports - targets: ["col_ue_bonus", "col_malus"], - render: function (data, type, row) { - return type === 'export' ? data.replace(/.*\+(\d?\d?\.\d\d).*/m, '$1').replace(/0(\d\..*)/, '$1') : data; - } - }, - { - // Elimine emoji warning sur UEs - targets: ["col_ues_validables"], - render: function (data, type, row) { - return type === 'export' ? data.replace(/(\d+\/\d+).*/, '$1') : data; - } - } - - ], - dom: 'Bfrtip', - buttons: buttons, - "drawCallback": function (settings) { - // permet de conserver l'ordre de tri des colonnes - let table = $('table.table_recap').DataTable(); - let order_info = JSON.stringify(table.order()); - if (formsemestre_id) { - localStorage.setItem(order_info_key, order_info); - } - let etudids = []; - document.querySelectorAll("td.identite_court").forEach(e => { - etudids.push(e.dataset.etudid); - }); - let noms = []; - document.querySelectorAll("td.identite_court").forEach(e => { - noms.push(e.dataset.nomprenom); - }); - localStorage.setItem(etudids_key, JSON.stringify(etudids)); - localStorage.setItem(noms_key, JSON.stringify(noms)); - }, - "order": order_info, - } - ); - update_buttons_labels(table); + order_info = JSON.parse(x); } catch (error) { - // l'erreur peut etre causee par un ancien storage: - localStorage.removeItem(etudids_key); - localStorage.removeItem(noms_key); - localStorage.removeItem(order_info_key); - location.reload(); + console.error(error); } - }); - $('table.table_recap tbody').on('click', 'tr', function () { - if ($(this).hasClass('selected')) { - $(this).removeClass('selected'); + } + } + + // Les colonnes visibles sont mémorisées, il faut initialiser l'état des boutons + function update_buttons_labels(dt) { + // chaque bouton controle une classe stockée dans le data-group du span + document.querySelectorAll("button.dt-button").forEach((but) => { + let g_span = but.querySelector("span > span"); + if (g_span) { + let group = g_span.dataset["group"]; + if (group) { + // si le group (= la 1ere col.) est visible, but_on + if (dt.columns("." + group).visible()[0]) { + but.classList.add("but_on"); + but.classList.remove("but_off"); + } else { + but.classList.add("but_off"); + but.classList.remove("but_on"); + } + } } - else { - $('table.table_recap tr.selected').removeClass('selected'); - $(this).addClass('selected'); - } - }); - // Pour montrer et surligner l'étudiant sélectionné: - $(function () { - let row_selected = document.querySelector(".row_selected"); - if (row_selected) { - row_selected.scrollIntoView(); - window.scrollBy(0, -125); - row_selected.classList.add("selected"); - } - }); + }); + } + + // Changement visibilité groupes colonnes (boutons) + function toggle_col_but_visibility(e, dt, node, config) { + let group = node.children()[0].firstChild.dataset.group; + toggle_col_group_visibility(dt, group, node.hasClass("but_on")); + } + function toggle_col_ident_visibility(e, dt, node, config) { + let onoff = node.hasClass("but_on"); + toggle_col_group_visibility(dt, "identite_detail", onoff); + toggle_col_group_visibility(dt, "identite_court", !onoff); + } + function toggle_col_ressources_visibility(e, dt, node, config) { + let onoff = node.hasClass("but_on"); + toggle_col_group_visibility(dt, "col_res", onoff); + toggle_col_group_visibility(dt, "col_ue_bonus", onoff); + toggle_col_group_visibility(dt, "col_malus", onoff); + } + function toggle_col_group_visibility(dt, group, onoff) { + if (onoff) { + dt.columns("." + group).visible(false); + } else { + dt.columns("." + group).visible(true); + } + update_buttons_labels(dt); + } + // Definition des boutons au dessus de la table: + let buttons = [ + { + extend: "copyHtml5", + text: "Copier", + exportOptions: { orthogonal: "export" }, + }, + { + extend: "excelHtml5", + // footer: true, // ne fonctionne pas ? + exportOptions: { orthogonal: "export" }, + title: document.querySelector("table.table_recap").dataset.filename, + }, + { + // force affichage de toutes les colonnes + text: '', + action: function (e, dt, node, config) { + dt.columns().visible(true); + update_buttons_labels(dt); + }, + }, + { + text: '🔄', + action: function (e, dt, node, config) { + localStorage.clear(); + console.log("cleared localStorage"); + location.reload(); + }, + }, + { + text: 'Civilité', + action: toggle_col_ident_visibility, + }, + { + text: 'Groupes', + action: toggle_col_but_visibility, + }, + { + text: 'Rg', + action: toggle_col_but_visibility, + }, + ]; // fin des boutons communs à toutes les tables recap + + if ($("table.table_recap").hasClass("jury")) { + // Table JURY: + // avec ou sans codes enregistrés + buttons.push({ + text: 'Codes jury', + action: toggle_col_but_visibility, + }); + if ($("table.table_recap").hasClass("apc")) { + // Boutons spécifiques à la table JURY BUT + buttons.push({ + text: 'Compétences', + action: toggle_col_but_visibility, + }); + buttons.push({ + text: 'RCUEs', + action: toggle_col_but_visibility, + }); + } + } else { + // BOUTONS SPECIFIQUES A LA TABLE RECAP NON JURY + buttons.push( + $("table.table_recap").hasClass("apc") + ? { + text: 'Ressources', + action: toggle_col_ressources_visibility, + } + : { + name: "toggle_mod", + text: "Cacher les modules", + action: function (e, dt, node, config) { + let onoff = node.hasClass("but_on"); + toggle_col_group_visibility( + dt, + "col_mod:not(.col_empty)", + onoff + ); + toggle_col_group_visibility(dt, "col_ue_bonus", onoff); + toggle_col_group_visibility(dt, "col_malus", onoff); + }, + } + ); + if ($("table.table_recap").hasClass("apc")) { + buttons.push({ + text: 'SAÉs', + action: toggle_col_but_visibility, + }); + } + // S'il y a des colonnes vides: + if ($("table.table_recap td.col_empty").length > 0) { + buttons.push({ + // modules vides + text: 'Vides', + action: toggle_col_but_visibility, + }); + } + // Boutons admission (pas en jury) + if (!$("table.table_recap").hasClass("jury")) { + buttons.push({ + text: 'Admission', + action: toggle_col_but_visibility, + }); + } + } + // Boutons évaluations (si présentes) + if ($("table.table_recap").hasClass("with_evaluations")) { + buttons.push({ + text: 'Évaluations', + action: toggle_col_but_visibility, + }); + } + + // ------------- LA TABLE --------- + try { + let table = $("table.table_recap").DataTable({ + paging: false, + searching: true, + info: false, + autoWidth: false, + fixedHeader: { + header: true, + footer: false, + }, + orderCellsTop: true, // cellules ligne 1 pour tri + aaSorting: [], // Prevent initial sorting + colReorder: true, + stateSave: true, // enregistre état de la table (tris, ...) + columnDefs: [ + { + // cache les codes, le détail de l'identité, les groupes, les colonnes admission et les vides + targets: hidden_colums, + visible: false, + }, + { + // Elimine les 0 à gauche pour les exports excel et les "copy" + targets: [ + "col_mod", + "col_moy_gen", + "col_moy_ue", + "col_res", + "col_sae", + "evaluation", + "col_rcue", + ], + render: function (data, type, row) { + return type === "export" ? data.replace(/0(\d\..*)/, "$1") : data; + }, + }, + { + // Elimine les "+"" pour les exports + targets: ["col_ue_bonus", "col_malus"], + render: function (data, type, row) { + return type === "export" + ? data + .replace(/.*\+(\d?\d?\.\d\d).*/m, "$1") + .replace(/0(\d\..*)/, "$1") + : data; + }, + }, + { + // Elimine emoji warning sur UEs + targets: ["col_ues_validables"], + render: function (data, type, row) { + return type === "export" + ? data.replace(/(\d+\/\d+).*/, "$1") + : data; + }, + }, + ], + dom: "Bfrtip", + buttons: buttons, + drawCallback: function (settings) { + // permet de conserver l'ordre de tri des colonnes + let table = $("table.table_recap").DataTable(); + let order_info = JSON.stringify(table.order()); + if (formsemestre_id) { + localStorage.setItem(order_info_key, order_info); + } + let etudids = []; + document.querySelectorAll("td.identite_court").forEach((e) => { + etudids.push(e.dataset.etudid); + }); + let noms = []; + document.querySelectorAll("td.identite_court").forEach((e) => { + noms.push(e.dataset.nomprenom); + }); + localStorage.setItem(etudids_key, JSON.stringify(etudids)); + localStorage.setItem(noms_key, JSON.stringify(noms)); + }, + order: order_info, + }); + update_buttons_labels(table); + } catch (error) { + // l'erreur peut etre causee par un ancien storage: + localStorage.removeItem(etudids_key); + localStorage.removeItem(noms_key); + localStorage.removeItem(order_info_key); + location.reload(); + } + }); + $("table.table_recap tbody").on("click", "tr", function () { + if ($(this).hasClass("selected")) { + $(this).removeClass("selected"); + } else { + $("table.table_recap tr.selected").removeClass("selected"); + $(this).addClass("selected"); + } + }); + // Pour montrer et surligner l'étudiant sélectionné: + $(function () { + let row_selected = document.querySelector(".row_selected"); + if (row_selected) { + row_selected.scrollIntoView(); + window.scrollBy(0, -125); + row_selected.classList.add("selected"); + } + }); + // Ajoute bulle aide sur colonne RCUEs + $(function () { + // explication colonne RCUEs + let th = document.querySelector( + "table.table_recap.apc th.col_rcues_validables" + ); + th.title = "RCUEs validables avec ces notes"; + }); }); From b026349e740d963cd957fe6ea66e1ff2e7cced25 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 19 Jun 2023 22:07:31 +0200 Subject: [PATCH 10/17] =?UTF-8?q?Affichage=20et=20suppression=20possible?= =?UTF-8?q?=20de=20toutes=20les=20d=C3=A9cisions=20de=20jury?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/jury.py | 144 +++++++++++++++++- app/but/jury_edit_manual.py | 66 ++++++++ app/models/etudiants.py | 6 + app/models/validations.py | 10 +- app/scodoc/sco_page_etud.py | 8 +- app/static/css/jury_delete_manual.css | 9 ++ .../jury/erase_decisions_annee_formation.j2 | 4 +- app/templates/jury/jury_delete_manual.j2 | 134 ++++++++++++++++ app/views/notes.py | 15 +- 9 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 app/but/jury_edit_manual.py create mode 100644 app/static/css/jury_delete_manual.css create mode 100644 app/templates/jury/jury_delete_manual.j2 diff --git a/app/api/jury.py b/app/api/jury.py index 28c0ae8bc..2800c77a9 100644 --- a/app/api/jury.py +++ b/app/api/jury.py @@ -12,11 +12,20 @@ from flask_json import as_json from flask_login import login_required import app -from app.api import api_bp as bp, api_web_bp +from app import db, log +from app.api import api_bp as bp, api_web_bp, tools from app.decorators import scodoc, permission_required from app.scodoc.sco_exceptions import ScoException from app.but import jury_but_results -from app.models import FormSemestre +from app.models import ( + ApcValidationAnnee, + ApcValidationRCUE, + FormSemestre, + Identite, + ScolarAutorisationInscription, + ScolarFormSemestreValidation, +) +from app.scodoc import sco_cache from app.scodoc.sco_permissions import Permission @@ -36,3 +45,134 @@ def decisions_jury(formsemestre_id: int): return rows else: raise ScoException("non implemente") + + +@bp.route( + "/etudiant//jury/validation_ue//delete", + methods=["POST"], +) +@api_web_bp.route( + "/etudiant//jury/validation_ue//delete", + methods=["POST"], +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@as_json +def validation_ue_delete(etudid: int, validation_id: int): + "Efface cette validation" + return _validation_ue_delete(etudid, validation_id) + + +@bp.route( + "/etudiant//jury/validation_formsemestre//delete", + methods=["POST"], +) +@api_web_bp.route( + "/etudiant//jury/validation_formsemestre//delete", + methods=["POST"], +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@as_json +def validation_formsemestre_delete(etudid: int, validation_id: int): + "Efface cette validation" + # c'est la même chose (formations classiques) + return _validation_ue_delete(etudid, validation_id) + + +def _validation_ue_delete(etudid: int, validation_id: int): + "Efface cette validation (semestres classiques ou UEs)" + etud = tools.get_etud(etudid) + if etud is None: + return "étudiant inconnu", 404 + validation = ScolarFormSemestreValidation.query.filter_by( + id=validation_id, etudid=etudid + ).first_or_404() + log(f"validation_ue_delete: etuid={etudid} {validation}") + db.session.delete(validation) + sco_cache.invalidate_formsemestre_etud(etud) + db.session.commit() + return "ok" + + +@bp.route( + "/etudiant//jury/autorisation_inscription//delete", + methods=["POST"], +) +@api_web_bp.route( + "/etudiant//jury/autorisation_inscription//delete", + methods=["POST"], +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@as_json +def autorisation_inscription_delete(etudid: int, validation_id: int): + "Efface cette validation" + etud = tools.get_etud(etudid) + if etud is None: + return "étudiant inconnu", 404 + validation = ScolarAutorisationInscription.query.filter_by( + id=validation_id, etudid=etudid + ).first_or_404() + log(f"autorisation_inscription_delete: etuid={etudid} {validation}") + db.session.delete(validation) + sco_cache.invalidate_formsemestre_etud(etud) + db.session.commit() + return "ok" + + +@bp.route( + "/etudiant//jury/validation_rcue//delete", + methods=["POST"], +) +@api_web_bp.route( + "/etudiant//jury/validation_rcue//delete", + methods=["POST"], +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@as_json +def validation_rcue_delete(etudid: int, validation_id: int): + "Efface cette validation" + etud = tools.get_etud(etudid) + if etud is None: + return "étudiant inconnu", 404 + validation = ApcValidationRCUE.query.filter_by( + id=validation_id, etudid=etudid + ).first_or_404() + log(f"validation_ue_delete: etuid={etudid} {validation}") + db.session.delete(validation) + sco_cache.invalidate_formsemestre_etud(etud) + db.session.commit() + return "ok" + + +@bp.route( + "/etudiant//jury/validation_annee_but//delete", + methods=["POST"], +) +@api_web_bp.route( + "/etudiant//jury/validation_annee_but//delete", + methods=["POST"], +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@as_json +def validation_annee_but_delete(etudid: int, validation_id: int): + "Efface cette validation" + etud = tools.get_etud(etudid) + if etud is None: + return "étudiant inconnu", 404 + validation = ApcValidationAnnee.query.filter_by( + id=validation_id, etudid=etudid + ).first_or_404() + log(f"validation_annee_but: etuid={etudid} {validation}") + db.session.delete(validation) + sco_cache.invalidate_formsemestre_etud(etud) + db.session.commit() + return "ok" diff --git a/app/but/jury_edit_manual.py b/app/but/jury_edit_manual.py new file mode 100644 index 000000000..73e21ecf5 --- /dev/null +++ b/app/but/jury_edit_manual.py @@ -0,0 +1,66 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes) + +Non spécifique au BUT. +""" + +import flask +from flask import flash, render_template, url_for +from flask import g, request + +from app import db + +from app.models import ( + ApcValidationAnnee, + ApcValidationRCUE, + FormSemestre, + Identite, + UniteEns, + ScolarAutorisationInscription, + ScolarFormSemestreValidation, +) +from app.views import ScoData + + +def jury_delete_manual(etud: Identite): + """Vue (réservée au chef de dept.) + présentant *toutes* les décisions de jury concernant cet étudiant + et permettant de les supprimer une à une. + """ + sem_vals = ScolarFormSemestreValidation.query.filter_by( + etudid=etud.id, ue_id=None + ).order_by(ScolarFormSemestreValidation.event_date) + ue_vals = ( + ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) + .join(UniteEns) + .order_by(ScolarFormSemestreValidation.event_date, UniteEns.numero) + ) + autorisations = ScolarAutorisationInscription.query.filter_by( + etudid=etud.id + ).order_by( + ScolarAutorisationInscription.semestre_id, ScolarAutorisationInscription.date + ) + rcue_vals = ( + ApcValidationRCUE.query.filter_by(etudid=etud.id) + .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id) + .order_by(UniteEns.semestre_idx, UniteEns.numero, ApcValidationRCUE.date) + ) + annee_but_vals = ApcValidationAnnee.query.filter_by(etudid=etud.id).order_by( + ApcValidationAnnee.ordre, ApcValidationAnnee.date + ) + return render_template( + "jury/jury_delete_manual.j2", + etud=etud, + sem_vals=sem_vals, + ue_vals=ue_vals, + autorisations=autorisations, + rcue_vals=rcue_vals, + annee_but_vals=annee_but_vals, + sco=ScoData(), + title=f"Toutes les décisions de jury enregistrées pour {etud.html_link_fiche()}", + ) diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 8a6047097..d67a84828 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -78,6 +78,12 @@ class Identite(db.Model): f"" ) + def html_link_fiche(self) -> str: + "lien vers la fiche" + return f"""{self.nomprenom}""" + @classmethod def from_request(cls, etudid=None, code_nip=None) -> "Identite": """Étudiant à partir de l'etudid ou du code_nip, soit diff --git a/app/models/validations.py b/app/models/validations.py index 9a938b6c5..8a1a8dd0d 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -76,12 +76,14 @@ class ScolarFormSemestreValidation(db.Model): d.pop("_sa_instance_state", None) return d - def html(self) -> str: + def html(self, detail=False) -> str: "Affichage html" if self.ue_id is not None: - return f"""Validation de l'UE {self.ue.acronyme} - ({self.code} - le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}) + return f"""Validation de l'UE {self.ue.acronyme} de {self.ue.formation.acronyme} + {("émise par " + self.formsemestre.html_link_status()) + if self.formsemestre else ""} + :{self.code} + le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")} """ else: return f"""Validation du semestre S{ diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 9901dad36..4b1973768 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -312,7 +312,13 @@ def ficheEtud(etudid=None): ] = f"""inscrire à un autre semestre""" + }">inscrire à un autre semestre + éditer toutes décisions de jury + """ + else: info["link_inscrire_ailleurs"] = "" else: diff --git a/app/static/css/jury_delete_manual.css b/app/static/css/jury_delete_manual.css new file mode 100644 index 000000000..6580e089f --- /dev/null +++ b/app/static/css/jury_delete_manual.css @@ -0,0 +1,9 @@ + +div.jury_decisions_list div { + font-size: 120%; + font-weight: bold; +} + +div.jury_decisions_list form { + display: inline-block; +} \ No newline at end of file diff --git a/app/templates/jury/erase_decisions_annee_formation.j2 b/app/templates/jury/erase_decisions_annee_formation.j2 index 5298437a5..7d0353eb8 100644 --- a/app/templates/jury/erase_decisions_annee_formation.j2 +++ b/app/templates/jury/erase_decisions_annee_formation.j2 @@ -3,7 +3,7 @@ {% block app_content %} {% if not validations %} -

    Aucune validation de jury enregistrée pour {{etud.nom_disp()}} sur +

    Aucune validation de jury enregistrée pour {{etud.html_link_fiche()}} sur l'année {{annee}} de la formation {{ formation.html() }}

    @@ -13,7 +13,7 @@ de la formation {{ formation.html() }} {% else %} -

    Effacer les décisions de jury pour l'année {{annee}} de {{etud.nom_disp()}} ?

    +

    Effacer les décisions de jury pour l'année {{annee}} de {{etud.html_link_fiche()}} ?

    Affectera toutes les décisions concernant l'année {{annee}} de la formation, quelle que soit leur origine.

    diff --git a/app/templates/jury/jury_delete_manual.j2 b/app/templates/jury/jury_delete_manual.j2 new file mode 100644 index 000000000..ed7a9d2e8 --- /dev/null +++ b/app/templates/jury/jury_delete_manual.j2 @@ -0,0 +1,134 @@ +{% extends 'base.j2' %} + +{% block styles %} + {{super()}} + +{% endblock %} + +{% block app_content %} + + +

    Décisions de jury enregistrées pour {{etud.html_link_fiche()|safe}}

    + +

    +Cette page liste toutes les décisions de jury connus de ScoDoc concernant cet étudiant +et permet de les effacer une par une. +

    +

    +Attention, il vous appartient de vérifier la cohérence du résultat ! +En principe, l'usage de cette page devrait rester exceptionnel. +Aucune annulation n'est ici possible (vous devrez re-saisir les décisions via les +pages de saisie de jury habituelles). +

    +{% if sem_vals.first() %} +
    +
    Décisions de semestres
    +
      + {% for v in sem_vals %} +
    • {{v.html()|safe}} +
      +
    • + {% endfor %} +
    +
    +{% endif %} + +{% if ue_vals.first() %} +
    +
    Décisions d'UEs
    +
      + {% for v in ue_vals %} +
    • {{v.html(detail=True)|safe}} +
      +
    • + {% endfor %} +
    +
    +{% endif %} + +{% if rcue_vals.first() %} +
    +
    Décisions de RCUE (niveaux de compétences)
    +
      + {% for v in rcue_vals %} +
    • {{v.html()|safe}} +
      +
    • + {% endfor %} +
    +
    +{% endif %} + +{% if annee_but_vals.first() %} +
    +
    Décisions d'années BUT
    +
      + {% for v in annee_but_vals %} +
    • {{v.html()|safe}} +
      +
    • + {% endfor %} +
    +
    +{% endif %} + +{% if autorisations.first() %} +
    +
    Autorisations d'inscriptions (passages)
    +
      + {% for v in autorisations %} +
    • {{v.html()|safe}} +
      +
    • + {% endfor %} +
    +
    +{% endif %} + +{% if not( + sem_vals.first() or sem_ues.first() or sem_rcues.first() + or annee_but_vals.first() or autorisations.first()) +%} +
    +

    aucune décision enregistrée

    +
    +{% endif %} + + + +{% endblock %} + + +{% block scripts %} +{{super()}} + + +{% endblock %} diff --git a/app/views/notes.py b/app/views/notes.py index e29a93c91..3a5e3bbe7 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -47,11 +47,12 @@ from app.but import jury_but, jury_but_validation_auto from app.but.forms import jury_but_forms from app.but import jury_but_pv from app.but import jury_but_view +from app.but import jury_edit_manual from app.comp import jury, res_sem from app.comp.res_compat import NotesTableCompat from app.models import Formation, ScolarAutorisationInscription, ScolarNews, Scolog -from app.models.but_refcomp import ApcNiveau, ApcParcours +from app.models.but_refcomp import ApcNiveau from app.models.config import ScoDocSiteConfig from app.models.etudiants import Identite from app.models.formsemestre import FormSemestre @@ -2940,6 +2941,18 @@ def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int): ) +@bp.route( + "/jury_delete_manual/", + methods=["GET", "POST"], +) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +def jury_delete_manual(etudid: int): + """Efface toute les décisions d'une année pour cet étudiant""" + etud: Identite = Identite.query.get_or_404(etudid) + return jury_edit_manual.jury_delete_manual(etud) + + sco_publish( "/formsemestre_lettres_individuelles", sco_pv_forms.formsemestre_lettres_individuelles, From 91b86f30a518e48cc572b5ec076972fcd84bf757 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 19 Jun 2023 22:32:04 +0200 Subject: [PATCH 11/17] fix html typos --- app/templates/jury/erase_decisions_annee_formation.j2 | 6 +++--- app/templates/jury/jury_delete_manual.j2 | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/templates/jury/erase_decisions_annee_formation.j2 b/app/templates/jury/erase_decisions_annee_formation.j2 index 7d0353eb8..0c231b532 100644 --- a/app/templates/jury/erase_decisions_annee_formation.j2 +++ b/app/templates/jury/erase_decisions_annee_formation.j2 @@ -3,8 +3,8 @@ {% block app_content %} {% if not validations %} -

    Aucune validation de jury enregistrée pour {{etud.html_link_fiche()}} sur -l'année {{annee}} +

    Aucune validation de jury enregistrée pour {{etud.html_link_fiche()|safe}} +sur l'année {{annee}} de la formation {{ formation.html() }}

    @@ -13,7 +13,7 @@ de la formation {{ formation.html() }} {% else %} -

    Effacer les décisions de jury pour l'année {{annee}} de {{etud.html_link_fiche()}} ?

    +

    Effacer les décisions de jury pour l'année {{annee}} de {{etud.html_link_fiche()|safe}} ?

    Affectera toutes les décisions concernant l'année {{annee}} de la formation, quelle que soit leur origine.

    diff --git a/app/templates/jury/jury_delete_manual.j2 b/app/templates/jury/jury_delete_manual.j2 index ed7a9d2e8..c7845637b 100644 --- a/app/templates/jury/jury_delete_manual.j2 +++ b/app/templates/jury/jury_delete_manual.j2 @@ -95,7 +95,8 @@ pages de saisie de jury habituelles). {% endif %}
    - retour à sa fiche +

    retour à la fiche de {{etud.html_link_fiche()|safe}} +

    {% endblock %} From 027224a7b3baf9fbc730e21a6db95c937b643288 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 19 Jun 2023 22:33:27 +0200 Subject: [PATCH 12/17] version --- sco_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sco_version.py b/sco_version.py index 02d22d18d..7a95f76a5 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.86" +SCOVERSION = "9.4.87" SCONAME = "ScoDoc" From d1d83e03272ad439c427054344b6f7f6a5bf26c1 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 07:51:40 +0200 Subject: [PATCH 13/17] Database creation: add unaccent postgresql extension. Tests unitaires OK. --- sco_version.py | 2 +- tests/api/test_api_permissions.py | 1 + tools/create_database.sh | 2 +- tools/debian/postinst | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/sco_version.py b/sco_version.py index 7a95f76a5..857492bf1 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.87" +SCOVERSION = "9.4.88" SCONAME = "ScoDoc" diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index 70d214467..9a5ff9acb 100644 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -60,6 +60,7 @@ def test_permissions(api_headers): "role_name": "Ens", "start": "abc", "uid": 1, + "validation_id": 1, "version": "long", } for rule in api_rules: diff --git a/tools/create_database.sh b/tools/create_database.sh index 6e9a939ed..d2863722b 100755 --- a/tools/create_database.sh +++ b/tools/create_database.sh @@ -31,4 +31,4 @@ source "$SCRIPT_DIR"/utils.sh || die "config.sh not found, exiting" # --- echo 'Creating postgresql database ' "$db_name" createdb -E UTF-8 -p "$POSTGRES_PORT" -O "$POSTGRES_USER" "$db_name" - +echo 'CREATE EXTENSION IF NOT EXISTS "unaccent";' | psql -p "$POSTGRES_PORT" "$db_name" "$POSTGRES_USER" diff --git a/tools/debian/postinst b/tools/debian/postinst index dad8205ee..c967a53ec 100755 --- a/tools/debian/postinst +++ b/tools/debian/postinst @@ -104,7 +104,7 @@ if [ "$n" == 1 ] then echo "Upgrading existing SCODOC database..." # Ajout extension unaccent (postgres superuser, ajout sur base SCODOC) - (cd /tmp; echo 'CREATE EXTENSION IF NOT EXISTS "unaccent";' | su -c psql postgres) + (cd /tmp; echo 'CREATE EXTENSION IF NOT EXISTS "unaccent";' | su -c "psql SCODOC" postgres) # Migrations gérées avec Flask-Migrate (Alembic/SQLAlchemy) # utilise les scripts dans migrations/version/ # pour mettre à jour notre base (en tant qu'utilisateur scodoc) From 47cf5962f9762138cfddbbb22218d6ddfbbff77f Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 12:14:16 +0200 Subject: [PATCH 14/17] =?UTF-8?q?Table=20jury:=20affichage=20stats=20codes?= =?UTF-8?q?=20annuels=20octroy=C3=A9s=20sous=20la=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_archives.py | 2 +- app/scodoc/sco_recapcomplet.py | 69 ++++++++++++++++++++-------------- app/static/css/scodoc.css | 2 + app/tables/jury_recap.py | 1 + 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py index 5e09bf96c..00e02c9de 100644 --- a/app/scodoc/sco_archives.py +++ b/app/scodoc/sco_archives.py @@ -344,7 +344,7 @@ def do_formsemestre_archive( if data: PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data) # Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes) - table_html, _ = gen_formsemestre_recapcomplet_html_table( + table_html, _, _ = gen_formsemestre_recapcomplet_html_table( formsemestre, res, include_evaluations=True ) if table_html: diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 9ebe4e87d..791fdb0f5 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -27,6 +27,7 @@ """Tableau récapitulatif des notes d'un semestre """ +import collections import datetime import time from xml.etree import ElementTree @@ -109,7 +110,7 @@ def formsemestre_recapcomplet( force_publishing=force_publishing, ) - table_html, table = _formsemestre_recapcomplet_to_html( + table_html, table, freq_codes_annuels = _formsemestre_recapcomplet_to_html( formsemestre, filename=filename, mode_jury=mode_jury, @@ -215,33 +216,37 @@ def formsemestre_recapcomplet( """ ) - if mode_jury and table and sum(table.freq_codes_annuels.values()) > 0: + if mode_jury and freq_codes_annuels and sum(freq_codes_annuels.values()) > 0: + nb_etud_avec_decision_annuelle = ( + sum(freq_codes_annuels.values()) - freq_codes_annuels["total"] + ) H.append( f"""
    -
    Nb d'étudiants avec décision annuelle: - {sum(table.freq_codes_annuels.values())} / {len(table)} +
    Nb d'étudiants avec décision annuelle: + {nb_etud_avec_decision_annuelle} / {freq_codes_annuels["total"]}
    -
    Codes annuels octroyés:
    - """ ) - for code in sorted(table.freq_codes_annuels.keys()): + if nb_etud_avec_decision_annuelle > 0: H.append( - f""" - - - - """ + """
    Codes annuels octroyés:
    +
    {code}{table.freq_codes_annuels[code]}{ - (100*table.freq_codes_annuels[code] / len(table)):2.1f}% -
    + """ ) - H.append( - """ -
    -
    - """ - ) + for code in sorted(freq_codes_annuels.keys()): + if code != "total": + H.append( + f""" + {code} + {freq_codes_annuels[code]} + { + (100*freq_codes_annuels[code] / freq_codes_annuels["total"]):2.1f}% + + """ + ) + H.append("""""") + H.append("""
    """) # Légende H.append( """ @@ -272,12 +277,12 @@ def _formsemestre_recapcomplet_to_html( filename: str = "", mode_jury=False, # saisie décisions jury selected_etudid=None, -) -> tuple[str, TableRecap]: +) -> tuple[str, TableRecap, collections.Counter]: """Le tableau recap en html""" if tabformat not in ("html", "evals"): raise ScoValueError("invalid table format") res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - table_html, table = gen_formsemestre_recapcomplet_html_table( + table_html, table, freq_codes_annuels = gen_formsemestre_recapcomplet_html_table( formsemestre, res, include_evaluations=(tabformat == "evals"), @@ -285,7 +290,7 @@ def _formsemestre_recapcomplet_to_html( filename=filename, selected_etudid=selected_etudid, ) - return table_html, table + return table_html, table, freq_codes_annuels def _formsemestre_recapcomplet_to_file( @@ -447,9 +452,9 @@ def gen_formsemestre_recapcomplet_html_table( mode_jury=False, filename="", selected_etudid=None, -) -> tuple[str, TableRecap]: +) -> tuple[str, TableRecap, collections.Counter]: """Construit table recap pour le BUT - Cache le résultat pour le semestre (sauf en mode jury). + Cache le résultat pour le semestre. Note: on cache le HTML et non l'objet Table. Si mode_jury, occultera colonnes modules (en js) @@ -461,6 +466,7 @@ def gen_formsemestre_recapcomplet_html_table( """ table = None table_html = None + table_html_cached = None cache_class = { (True, True): sco_cache.TableJuryWithEvalsCache, (True, False): sco_cache.TableJuryCache, @@ -468,8 +474,8 @@ def gen_formsemestre_recapcomplet_html_table( (False, False): sco_cache.TableRecapCache, }[(bool(mode_jury), bool(include_evaluations))] if not selected_etudid: - table_html = cache_class.get(formsemestre.id) - if table_html is None: + table_html_cached = cache_class.get(formsemestre.id) + if table_html_cached is None: table = _gen_formsemestre_recapcomplet_table( res, include_evaluations, @@ -478,9 +484,14 @@ def gen_formsemestre_recapcomplet_html_table( selected_etudid=selected_etudid, ) table_html = table.html() - cache_class.set(formsemestre.id, table_html) + freq_codes_annuels = ( + table.freq_codes_annuels if hasattr(table, "freq_codes_annuels") else None + ) + cache_class.set(formsemestre.id, (table_html, freq_codes_annuels)) + else: + table_html, freq_codes_annuels = table_html_cached - return table_html, table + return table_html, table, freq_codes_annuels def _gen_formsemestre_recapcomplet_table( diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 4f922c9d9..172e90081 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -2773,6 +2773,8 @@ table.notes_recapcomplet a:hover { div.table_recap_caption { width: fit-content; + margin-top: 8px; + margin-bottom: 8px; padding: 8px; border-radius: 8px; background-color: rgb(202, 255, 180); diff --git a/app/tables/jury_recap.py b/app/tables/jury_recap.py index 8d02065cf..3e6f5d24d 100644 --- a/app/tables/jury_recap.py +++ b/app/tables/jury_recap.py @@ -78,6 +78,7 @@ class TableJury(TableRecap): dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id) if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau row.add_rcue_cols(dec_rcue) + self.freq_codes_annuels["total"] = len(self.rows) def add_jury(self): """Ajoute la colonne code jury et le lien. From 8156cce4be039582f7f666b1c46d66440e4f5e44 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 14:26:01 +0200 Subject: [PATCH 15/17] Fix typo --- app/templates/jury/jury_delete_manual.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/jury/jury_delete_manual.j2 b/app/templates/jury/jury_delete_manual.j2 index c7845637b..e423501fb 100644 --- a/app/templates/jury/jury_delete_manual.j2 +++ b/app/templates/jury/jury_delete_manual.j2 @@ -86,7 +86,7 @@ pages de saisie de jury habituelles). {% endif %} {% if not( - sem_vals.first() or sem_ues.first() or sem_rcues.first() + sem_vals.first() or ue_vals.first() or rcue_vals.first() or annee_but_vals.first() or autorisations.first()) %}
    From f4277a13366f4eb205dff8230633f77173e8f365 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 19:56:20 +0200 Subject: [PATCH 16/17] =?UTF-8?q?Jury=20BUT:=20effacement=20d=C3=A9cision?= =?UTF-8?q?=20ann=C3=A9e=20+=202=20petits=20bugs=20mineurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index af9b46731..6861c8ecd 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -931,7 +931,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): # XXX efface les validations émise depuis ce semestre # et pas toutes celles concernant cette l'année... # (utiliser formation_id pour changer cette politique) - formsemestre_id=self.formsemestre_impair.id, + formsemestre_id=self.formsemestre.id, ordre=self.annee_but, ) for validation in validations: @@ -1286,7 +1286,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): sco_cache.invalidate_formsemestre( formsemestre_id=validation_rcue.formsemestre_id ) - else: + elif ue1 and ue2: # Crée nouvelle validation validation_rcue = ApcValidationRCUE( etudid=self.etud.id, ue1_id=ue1.id, ue2_id=ue2.id, code=sco_codes.ADSUP @@ -1380,20 +1380,20 @@ class DecisionsProposeesRCUE(DecisionsProposees): "Impossible de valider le niveau de compétence inférieur: pas 2 UEs associées'", "warning", ) - return + return [], None, None ues_impaires = [ue for ue in ues if ue.semestre_idx % 2] if len(ues_impaires) != 1: flash( "Impossible de valider le niveau de compétence inférieur: pas d'UE impaire associée" ) - return + return [], None, None ue1 = ues_impaires[0] ues_paires = [ue for ue in ues if not ue.semestre_idx % 2] if len(ues_paires) != 1: flash( "Impossible de valider le niveau de compétence inférieur: pas d'UE paire associée" ) - return + return [], None, None ue2 = ues_paires[0] return ues, ue1, ue2 From ccc589f4d51f593d5baaa8f4f5b766669c3c878e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 21:01:40 +0200 Subject: [PATCH 17/17] =?UTF-8?q?Modifie=20effacement=20d=C3=A9cisions=20a?= =?UTF-8?q?nnuelles=20BUT=20et=20RCUE.=20Am=C3=A9liore=20affichage=20d?= =?UTF-8?q?=C3=A9cisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 35 +++++++++++-------- app/models/validations.py | 14 +++++--- app/static/css/jury_delete_manual.css | 6 +++- .../but/formsemestre_validation_auto_but.j2 | 10 ++++-- app/views/notes.py | 7 +++- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 6861c8ecd..83b1d9ddb 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -902,6 +902,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): Efface même si étudiant DEM ou DEF. Si à cheval ou only_one_sem, n'efface que les décisions UE et les autorisations de passage du semestre d'origine du deca. + + Dans tous les cas, efface les validations de l'année en cours. (commite la session.) """ if only_one_sem or self.a_cheval: @@ -916,8 +918,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): else: for dec_ue in self.decisions_ues.values(): dec_ue.erase() - for dec_rcue in self.decisions_rcue_by_niveau.values(): - dec_rcue.erase() + if self.formsemestre_impair: ScolarAutorisationInscription.delete_autorisation_etud( self.etud.id, self.formsemestre_impair.id @@ -926,21 +927,27 @@ class DecisionsProposeesAnnee(DecisionsProposees): ScolarAutorisationInscription.delete_autorisation_etud( self.etud.id, self.formsemestre_pair.id ) - validations = ApcValidationAnnee.query.filter_by( + # Efface les RCUEs + for dec_rcue in self.decisions_rcue_by_niveau.values(): + dec_rcue.erase() + + # Efface les validations concernant l'année BUT + # de ce semestre + validations = ( + ApcValidationAnnee.query.filter_by( etudid=self.etud.id, - # XXX efface les validations émise depuis ce semestre - # et pas toutes celles concernant cette l'année... - # (utiliser formation_id pour changer cette politique) - formsemestre_id=self.formsemestre.id, ordre=self.annee_but, ) - for validation in validations: - db.session.delete(validation) - Scolog.logdb( - "jury_but", - etudid=self.etud.id, - msg=f"Validation année BUT{self.annee_but}: effacée", - ) + .join(Formation) + .filter_by(formation_code=self.formsemestre.formation.formation_code) + ) + for validation in validations: + db.session.delete(validation) + Scolog.logdb( + "jury_but", + etudid=self.etud.id, + msg=f"Validation année BUT{self.annee_but}: effacée", + ) # Efface éventuelles validations de semestre # (en principe inutilisées en BUT) diff --git a/app/models/validations.py b/app/models/validations.py index 8a1a8dd0d..7686d7897 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -79,17 +79,23 @@ class ScolarFormSemestreValidation(db.Model): def html(self, detail=False) -> str: "Affichage html" if self.ue_id is not None: - return f"""Validation de l'UE {self.ue.acronyme} de {self.ue.formation.acronyme} + return f"""Validation de l'UE {self.ue.acronyme} + {('parcours ' + + ", ".join([p.code for p in self.ue.parcours])) + + "" + if self.ue.parcours else ""} + de {self.ue.formation.acronyme} {("émise par " + self.formsemestre.html_link_status()) if self.formsemestre else ""} - :{self.code} + : {self.code} le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")} """ else: return f"""Validation du semestre S{ self.formsemestre.semestre_id if self.formsemestre else "?"} - ({self.code} - le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}) + {self.formsemestre.html_link_status() if self.formsemestre else ""} + : {self.code} + le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")} """ diff --git a/app/static/css/jury_delete_manual.css b/app/static/css/jury_delete_manual.css index 6580e089f..6fa14f9ea 100644 --- a/app/static/css/jury_delete_manual.css +++ b/app/static/css/jury_delete_manual.css @@ -6,4 +6,8 @@ div.jury_decisions_list div { div.jury_decisions_list form { display: inline-block; -} \ No newline at end of file +} + +span.parcours { + color:blueviolet; +} diff --git a/app/templates/but/formsemestre_validation_auto_but.j2 b/app/templates/but/formsemestre_validation_auto_but.j2 index db7de789a..27334aac5 100644 --- a/app/templates/but/formsemestre_validation_auto_but.j2 +++ b/app/templates/but/formsemestre_validation_auto_but.j2 @@ -26,9 +26,13 @@ En conséquence, saisir ensuite manuellement les décisions manquantes, notamment sur les UEs en dessous de 10.

    -

    - Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure ! -

    +
    +
      +
    • Ne jamais lancer ce calcul avant que toutes les notes ne soient saisies ! + (verrouiller le semestre ensuite) +
    • +
    • Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure !
    • +
    diff --git a/app/views/notes.py b/app/views/notes.py index 3a5e3bbe7..cf5da2e36 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -2898,7 +2898,12 @@ def formsemestre_jury_but_erase(formsemestre_id: int, etudid: int = None): ) + """

    Les décisions des années scolaires précédentes ne seront pas modifiées.

    -
    Cette opération est irréversible !
    +

    Efface aussi toutes les validations concernant l'année BUT de ce semestre, + même si elles ont été acquises ailleurs. +

    +
    Cette opération est irréversible ! + A n'utiliser que dans des cas exceptionnels, vérifiez bien tous les étudiants ensuite. +
    """, cancel_url=dest_url, )