diff --git a/app/__init__.py b/app/__init__.py index 6fb6cf052..51e122cd5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -550,3 +550,22 @@ def scodoc_flash_status_messages(): f"Mode test: mails redirigés vers {email_test_mode_address}", category="warning", ) + + +def critical_error(msg): + """Handle a critical error: flush all caches, display message to the user""" + import app.scodoc.sco_utils as scu + + log(f"\n*** CRITICAL ERROR: {msg}") + send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg) + clear_scodoc_cache() + raise ScoValueError( + f""" + Une erreur est survenue. + + Si le problème persiste, merci de contacter le support ScoDoc via + {scu.SCO_DISCORD_ASSISTANCE} + + {msg} + """ + ) diff --git a/app/api/partitions.py b/app/api/partitions.py index 699f18e71..42307656b 100644 --- a/app/api/partitions.py +++ b/app/api/partitions.py @@ -19,6 +19,7 @@ from app.models import FormSemestre, FormSemestreInscription, Identite from app.models import GroupDescr, Partition from app.models.groups import group_membership from app.scodoc import sco_cache +from app.scodoc import sco_groups from app.scodoc.sco_permissions import Permission from app.scodoc import sco_utils as scu @@ -170,24 +171,15 @@ def set_etud_group(etudid: int, group_id: int): query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group = query.first_or_404() + if not group.partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") if etud.id not in {e.id for e in group.partition.formsemestre.etuds}: return json_error(404, "etud non inscrit au formsemestre du groupe") - groups = ( - GroupDescr.query.filter_by(partition_id=group.partition.id) - .join(group_membership) - .filter_by(etudid=etudid) + + sco_groups.change_etud_group_in_partition( + etudid, group_id, group.partition.to_dict() ) - ok = False - for other_group in groups: - if other_group.id == group_id: - ok = True - else: - other_group.etuds.remove(etud) - if not ok: - group.etuds.append(etud) - log(f"set_etud_group({etud}, {group})") - db.session.commit() - sco_cache.invalidate_formsemestre(group.partition.formsemestre_id) + return jsonify({"group_id": group_id, "etudid": etudid}) @@ -207,6 +199,8 @@ def group_remove_etud(group_id: int, etudid: int): query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group = query.first_or_404() + if not group.partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") if etud in group.etuds: group.etuds.remove(etud) db.session.commit() @@ -232,6 +226,8 @@ def partition_remove_etud(partition_id: int, etudid: int): if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition = query.first_or_404() + if not partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") groups = ( GroupDescr.query.filter_by(partition_id=partition_id) .join(group_membership) @@ -262,8 +258,10 @@ def group_create(partition_id: int): if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition: Partition = query.first_or_404() + if not partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") if not partition.groups_editable: - return json_error(404, "partition non editable") + return json_error(403, "partition non editable") data = request.get_json(force=True) # may raise 400 Bad Request group_name = data.get("group_name") if group_name is None: @@ -294,8 +292,10 @@ def group_delete(group_id: int): query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group: GroupDescr = query.first_or_404() + if not group.partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") if not group.partition.groups_editable: - return json_error(404, "partition non editable") + return json_error(403, "partition non editable") formsemestre_id = group.partition.formsemestre_id log(f"deleting {group}") db.session.delete(group) @@ -318,8 +318,10 @@ def group_edit(group_id: int): query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group: GroupDescr = query.first_or_404() + if not group.partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") if not group.partition.groups_editable: - return json_error(404, "partition non editable") + return json_error(403, "partition non editable") data = request.get_json(force=True) # may raise 400 Bad Request group_name = data.get("group_name") if group_name is not None: @@ -358,6 +360,8 @@ def partition_create(formsemestre_id: int): if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + if not formsemestre.etat: + return json_error(403, "formsemestre verrouillé") data = request.get_json(force=True) # may raise 400 Bad Request partition_name = data.get("partition_name") if partition_name is None: @@ -406,6 +410,8 @@ def formsemestre_order_partitions(formsemestre_id: int): if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + if not formsemestre.etat: + return json_error(403, "formsemestre verrouillé") partition_ids = request.get_json(force=True) # may raise 400 Bad Request if not isinstance(partition_ids, int) and not all( isinstance(x, int) for x in partition_ids @@ -443,6 +449,8 @@ def partition_order_groups(partition_id: int): if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition: Partition = query.first_or_404() + if not partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") group_ids = request.get_json(force=True) # may raise 400 Bad Request if not isinstance(group_ids, int) and not all( isinstance(x, int) for x in group_ids @@ -484,6 +492,8 @@ def partition_edit(partition_id: int): if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition: Partition = query.first_or_404() + if not partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") data = request.get_json(force=True) # may raise 400 Bad Request modified = False partition_name = data.get("partition_name") @@ -542,6 +552,8 @@ def partition_delete(partition_id: int): if g.scodoc_dept: query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) partition: Partition = query.first_or_404() + if not partition.formsemestre.etat: + return json_error(403, "formsemestre verrouillé") if not partition.partition_name: return json_error(404, "ne peut pas supprimer la partition par défaut") is_parcours = partition.is_parcours() diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 15ac1fa37..bc72ae860 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -13,7 +13,7 @@ Classe raccordant avec ScoDoc 7: avec la même interface. """ - +import collections from typing import Union from flask import g, url_for @@ -47,12 +47,14 @@ from app.models.validations import ScolarFormSemestreValidation from app.scodoc import sco_codes_parcours as sco_codes from app.scodoc.sco_codes_parcours import RED, UE_STANDARD from app.scodoc import sco_utils as scu -from app.scodoc.sco_exceptions import ScoException, ScoValueError +from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError from app.scodoc import sco_cursus_dut class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): + """Pour compat ScoDoc 7: à revoir pour le BUT""" + def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT): super().__init__(etud, formsemestre_id, res) # Ajustements pour le BUT @@ -65,3 +67,117 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): def parcours_validated(self): "True si le parcours est validé" return False # XXX TODO + + +class EtudCursusBUT: + """L'état de l'étudiant dans son cursus BUT + Liste des niveaux validés/à valider + """ + + def __init__(self, etud: Identite, formation: Formation): + """formation indique la spécialité préparée""" + # Vérifie que l'étudiant est bien inscrit à un sem. de cette formation + if formation.id not in ( + ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions + ): + raise ScoValueError( + f"{etud.nomprenom} non inscrit dans {formation.titre} v{formation.version}" + ) + if not formation.referentiel_competence: + raise ScoNoReferentielCompetences(formation=formation) + # + self.etud = etud + self.formation = formation + self.inscriptions = sorted( + [ + ins + for ins in etud.formsemestre_inscriptions + if ins.formsemestre.formation.referentiel_competence + and ( + ins.formsemestre.formation.referentiel_competence.id + == formation.referentiel_competence.id + ) + ], + key=lambda s: (s.formsemestre.semestre_id, s.formsemestre.date_debut), + ) + "Liste des inscriptions aux sem. de la formation, triées par indice et chronologie" + 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 }" + self.niveaux: dict[int, ApcNiveau] = {} + "cache les niveaux" + for annee in (1, 2, 3): + niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours( + annee, self.parcour + )[1] + # groupe les niveaux de tronc commun et ceux spécifiques au parcour + self.niveaux_by_annee[annee] = niveaux_d["TC"] + ( + niveaux_d[self.parcour.id] if self.parcour else [] + ) + 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, ... } }" + for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): + niveau = validation_rcue.niveau() + if not niveau.competence.id in self.validation_par_competence_et_annee: + self.validation_par_competence_et_annee[niveau.competence.id] = {} + previous_validation = self.validation_par_competence_et_annee.get( + niveau.competence.id + ).get(validation_rcue.annee()) + # 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] + ): + self.validation_par_competence_et_annee[niveau.competence.id][ + niveau.annee + ] = validation_rcue + + self.competences = { + competence.id: competence + for competence in ( + self.parcour.query_competences() + if self.parcour + else self.formation.referentiel_competence.get_competences_tronc_commun() + ) + } + "cache { competence_id : competence }" + + def to_dict(self): + """ + { + competence_id : { + annee : meilleure_validation + } + } + """ + return { + competence.id: { + annee: { + self.validation_par_competence_et_annee.get(competence.id, {}).get( + annee + ) + } + for annee in ("BUT1", "BUT2", "BUT3") + } + for competence in self.competences.values() + } diff --git a/app/but/jury_but.py b/app/but/jury_but.py index c3e5b856a..244989edd 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -64,7 +64,7 @@ import re from typing import Union import numpy as np -from flask import g, url_for +from flask import flash, g, url_for from app import db from app import log @@ -554,7 +554,6 @@ class DecisionsProposeesAnnee(DecisionsProposees): """Liste des regroupements d'UE à considérer cette année. On peut avoir un RCUE à cheval sur plusieurs années (redoublants avec UE capitalisées). Si on n'a pas les deux semestres, aucun RCUE. - Raises ScoValueError s'il y a des UE sans RCUE. <= ??? XXX """ if self.formsemestre_pair is None or self.formsemestre_impair is None: return [] @@ -570,6 +569,10 @@ class DecisionsProposeesAnnee(DecisionsProposees): not in CODES_UE_VALIDES ): continue # ignore cette UE antérieure non capitalisée + # et l'UE impaire doit être actuellement meilleure que + # celle éventuellement capitalisée + if self.decisions_ues[ue_impair.id].ue_status["is_capitalized"]: + continue # ignore cette UE car capitalisée et actuelle moins bonne if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id: rcue = RegroupementCoherentUE( self.etud, @@ -690,20 +693,20 @@ class DecisionsProposeesAnnee(DecisionsProposees): db.session.commit() - def record(self, code: str, no_overwrite=False): + def record(self, code: str, no_overwrite=False) -> 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. """ if self.inscription_etat != scu.INSCRIT: - return + return False if code and not code in self.codes: raise ScoValueError( f"code annee {html.escape(code)} invalide pour formsemestre {html.escape(self.formsemestre)}" ) if code == self.code_valide or (self.code_valide is not None and no_overwrite): self.recorded = True - return # no change + return False # no change if self.validation: db.session.delete(self.validation) db.session.commit() @@ -743,9 +746,10 @@ class DecisionsProposeesAnnee(DecisionsProposees): next_semestre_id, ) - self.recorded = True db.session.commit() + self.recorded = True self.invalidate_formsemestre_cache() + return True def invalidate_formsemestre_cache(self): "invalide le résultats des deux formsemestres" @@ -756,13 +760,20 @@ class DecisionsProposeesAnnee(DecisionsProposees): if self.formsemestre_pair is not None: sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id) - def record_all(self, no_overwrite: bool = True): + def record_all( + self, no_overwrite: bool = True, only_validantes: bool = False + ) -> bool: """Enregistre les codes qui n'ont pas été spécifiés par le formulaire, et sont donc en mode "automatique". - Si "à cheval", ne modifie pas les codes UE de l'année scolaire précédente. - Pour les RCUE: n'enregistre que si la nouvelle décision est plus favorable que l'ancienne. + + Si only_validantes, n'enregistre que des décisions "validantes" de droit: ADM ou CMP. + + Return: True si au moins un code modifié et enregistré. """ - # Toujours valider dans l'ordre UE, RCUE, Année: + modif = False + # Toujours valider dans l'ordre UE, RCUE, Année annee_scolaire = self.formsemestre.annee_scolaire() # UEs for dec_ue in self.decisions_ues.values(): @@ -771,25 +782,40 @@ class DecisionsProposeesAnnee(DecisionsProposees): ) and dec_ue.formsemestre.annee_scolaire() == annee_scolaire: # rappel: le code par défaut est en tête code = dec_ue.codes[0] if dec_ue.codes else None - # enregistre le code jury seulement s'il n'y a pas déjà de code - # (no_overwrite=True) sauf en mode test yaml - dec_ue.record(code, no_overwrite=no_overwrite) - # RCUE : enregistre seulement si pas déjà validé "mieux" + if (not only_validantes) or code in sco_codes.CODES_UE_VALIDES_DE_DROIT: + # enregistre le code jury seulement s'il n'y a pas déjà de code + # (no_overwrite=True) sauf en mode test yaml + modif |= dec_ue.record(code, no_overwrite=no_overwrite) + # RCUE : for dec_rcue in self.decisions_rcue_by_niveau.values(): code = dec_rcue.codes[0] if dec_rcue.codes else None - if (not dec_rcue.recorded) and ( - (not dec_rcue.validation) - or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0) - < BUT_CODES_ORDERED.get(code, 0) + if ( + (not dec_rcue.recorded) + and ( # enregistre seulement si pas déjà validé "mieux" + (not dec_rcue.validation) + or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0) + < BUT_CODES_ORDERED.get(code, 0) + ) + and ( # décision validante de droit ? + ( + (not only_validantes) + or code in sco_codes.CODES_RCUE_VALIDES_DE_DROIT + ) + ) ): - dec_rcue.record(code, no_overwrite=no_overwrite) + modif |= dec_rcue.record(code, no_overwrite=no_overwrite) # Année: if not self.recorded: # rappel: le code par défaut est en tête code = self.codes[0] if self.codes else None # enregistre le code jury seulement s'il n'y a pas déjà de code # (no_overwrite=True) sauf en mode test yaml - self.record(code, no_overwrite=no_overwrite) + if ( + not only_validantes + ) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT: + modif |= self.record(code, no_overwrite=no_overwrite) + + return modif def erase(self, only_one_sem=False): """Efface les décisions de jury de cet étudiant @@ -1002,23 +1028,23 @@ class DecisionsProposeesRCUE(DecisionsProposees): return f"""<{self.__class__.__name__} rcue={self.rcue} valid={self.code_valide } codes={self.codes} explanation={self.explanation}""" - def record(self, code: str, no_overwrite=False): + def record(self, code: str, no_overwrite=False) -> bool: """Enregistre le code RCUE. Note: - si le RCUE est ADJ, les UE non validées sont passées à ADJ XXX on pourra imposer ici d'autres règles de cohérence """ if self.rcue is None: - return # pas de RCUE a enregistrer + return False # pas de RCUE a enregistrer if self.inscription_etat != scu.INSCRIT: - return + return False if code and not code in self.codes: raise ScoValueError( f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" ) if code == self.code_valide or (self.code_valide is not None and no_overwrite): self.recorded = True - return # no change + return False # no change parcours_id = self.parcour.id if self.parcour is not None else None if self.validation: db.session.delete(self.validation) @@ -1051,7 +1077,13 @@ class DecisionsProposeesRCUE(DecisionsProposees): dec_ue = deca.decisions_ues.get(ue_id) if dec_ue and dec_ue.code_valide not in CODES_UE_VALIDES: log(f"rcue.record: force ADJR sur {dec_ue}") - dec_ue.record("ADJR") + flash( + f"""UEs du RCUE "{dec_ue.ue.niveau_competence.competence.titre}" passées en ADJR""" + ) + dec_ue.record(sco_codes.ADJR) + + # Valide les niveaux inférieurs de la compétence (code ADSUP) + # TODO if self.rcue.formsemestre_1 is not None: sco_cache.invalidate_formsemestre( @@ -1063,6 +1095,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): ) self.code_valide = code # mise à jour état self.recorded = True + return True def erase(self): """Efface la décision de jury de cet étudiant pour cet RCUE""" @@ -1194,9 +1227,10 @@ class DecisionsProposeesUE(DecisionsProposees): self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes self.explanation = "notes insuffisantes" - def record(self, code: str, no_overwrite=False): + def record(self, code: str, no_overwrite=False) -> bool: """Enregistre le code jury pour cette UE. Si no_overwrite, n'enregistre pas s'il y a déjà un code. + Return: True si code enregistré (modifié) """ if code and not code in self.codes: raise ScoValueError( @@ -1204,7 +1238,7 @@ class DecisionsProposeesUE(DecisionsProposees): ) if code == self.code_valide or (self.code_valide is not None and no_overwrite): self.recorded = True - return # no change + return False # no change self.erase() if code is None: self.validation = None @@ -1235,6 +1269,7 @@ class DecisionsProposeesUE(DecisionsProposees): sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id) self.code_valide = code # mise à jour self.recorded = True + return True def erase(self): """Efface la décision de jury de cet étudiant pour cette UE""" diff --git a/app/but/jury_but_recap.py b/app/but/jury_but_recap.py index 386579575..809294408 100644 --- a/app/but/jury_but_recap.py +++ b/app/but/jury_but_recap.py @@ -284,6 +284,10 @@ class RowCollector: self["_nom_disp_order"] = etud.sort_key self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail") self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court") + self["_nom_short_data"] = { + "etudid": etud.id, + "nomprenom": etud.nomprenom, + } if with_links: self["_nom_short_order"] = etud.sort_key self["_nom_short_target"] = url_for( @@ -368,10 +372,6 @@ class RowCollector: + ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""), "col_rcue col_rcues_validables" + klass, ) - self["_rcues_validables_data"] = { - "etudid": deca.etud.id, - "nomprenom": deca.etud.nomprenom, - } if len(deca.rcues_annee) > 0: # permet un tri par nb de niveaux validables + moyenne gen indicative S_pair if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen: diff --git a/app/but/jury_but_validation_auto.py b/app/but/jury_but_validation_auto.py index d5e308ab9..674389b54 100644 --- a/app/but/jury_but_validation_auto.py +++ b/app/but/jury_but_validation_auto.py @@ -18,29 +18,29 @@ from app.scodoc.sco_exceptions import ScoValueError def formsemestre_validation_auto_but( formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True ) -> int: - """Calcul automatique des décisions de jury sur une année BUT. - Ne modifie jamais de décisions de l'année scolaire précédente, même + """Calcul automatique des décisions de jury sur une "année" BUT. + + - N'enregistre jamais de décisions de l'année scolaire précédente, même si on a des RCUE "à cheval". - Normalement, only_adm est True et on n'enregistre que les décisions ADM (de droit). - Si only_adm est faux, on enregistre la première décision proposée par ScoDoc - (mode à n'utiliser que pour les tests) + - Ne modifie jamais de décisions déjà enregistrées (sauf si no_overwrite est faux, + ce qui est utilisé pour certains tests unitaires). + - Normalement, only_adm est True et on n'enregistre que les décisions validantes + de droit: ADM ou CMP. + En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc + (mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys) - Si no_overwrite est vrai (défaut), ne ré-écrit jamais les codes déjà enregistrés - (utiliser faux pour certains tests) - - Returns: nombre d'étudiants "admis" + Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code. """ if not formsemestre.formation.is_apc(): raise ScoValueError("fonction réservée aux formations BUT") - nb_admis = 0 + nb_etud_modif = 0 with sco_cache.DeferredSemCacheManager(): for etudid in formsemestre.etuds_inscriptions: etud: Identite = Identite.query.get(etudid) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) - if deca.admis: # année réussie - nb_admis += 1 - if deca.admis or not only_adm: - deca.record_all(no_overwrite=no_overwrite) + nb_etud_modif += deca.record_all( + no_overwrite=no_overwrite, only_validantes=only_adm + ) db.session.commit() - return nb_admis + return nb_etud_modif diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 3140bd02e..acb6b695b 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -196,7 +196,7 @@ def _gen_but_niveau_ue(
UE en cours { "sans notes" if np.isnan(dec_ue.moy_ue) else - ("avec moyenne" + scu.fmt_note(dec_ue.moy_ue)) + ("avec moyenne " + scu.fmt_note(dec_ue.moy_ue) + "") }
@@ -205,9 +205,10 @@ def _gen_but_niveau_ue( moy_ue_str = f"""{scu.fmt_note(dec_ue.moy_ue)}""" if dec_ue.code_valide: scoplement = f"""
- Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")} +
Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")} à {dec_ue.validation.event_date.strftime("%Hh%M")}
+
""" else: scoplement = "" diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index dfced34cc..1fccbda15 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -39,6 +39,7 @@ from dataclasses import dataclass import numpy as np import pandas as pd +import app from app import db from app.models import Evaluation, EvaluationUEPoids, ModuleImpl from app.scodoc import sco_cache @@ -484,7 +485,8 @@ class ModuleImplResultsClassic(ModuleImplResults): if nb_etuds == 0: return pd.Series() evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1) - assert evals_coefs.shape == (nb_evals,) + if evals_coefs.shape != (nb_evals,): + app.critical_error("compute_module_moy: vals_coefs.shape != nb_evals") evals_notes_20 = self.get_eval_notes_sur_20(modimpl) # Les coefs des évals pour chaque étudiant: là où il a des notes # non neutralisées diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 902d24050..7632ec28b 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -543,6 +543,10 @@ class ResultatsSemestre(ResultatsCache): formsemestre_id=self.formsemestre.id, etudid=etudid, ) + row["_nom_short_data"] = { + "etudid": etud.id, + "nomprenom": etud.nomprenom, + } row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"' row["_nom_disp_target"] = row["_nom_short_target"] row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"] @@ -905,7 +909,7 @@ class ResultatsSemestre(ResultatsCache): } first = True for i, cid in enumerate(fields): - titles[f"_{cid}_col_order"] = 10000 + i # tout à droite + titles[f"_{cid}_col_order"] = 100000 + i # tout à droite if first: titles[f"_{cid}_class"] = "admission admission_first" first = False diff --git a/app/decorators.py b/app/decorators.py index 83441275e..5338828f4 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -16,6 +16,7 @@ import flask_login import app from app.auth.models import User import app.scodoc.sco_utils as scu +from app.scodoc.sco_exceptions import ScoValueError class ZUser(object): @@ -180,19 +181,24 @@ def scodoc7func(func): else: arg_names = argspec.args for arg_name in arg_names: # pour chaque arg de la fonction vue - if arg_name == "REQUEST": # ne devrait plus arriver ! - # debug check, TODO remove after tests - raise ValueError("invalid REQUEST parameter !") - else: - # peut produire une KeyError s'il manque un argument attendu: - v = req_args[arg_name] - # try to convert all arguments to INTEGERS - # necessary for db ids and boolean values - try: - v = int(v) - except (ValueError, TypeError): - pass - pos_arg_values.append(v) + # peut produire une KeyError s'il manque un argument attendu: + v = req_args[arg_name] + # try to convert all arguments to INTEGERS + # necessary for db ids and boolean values + try: + v = int(v) if v else v + except (ValueError, TypeError) as exc: + if arg_name in { + "etudid", + "formation_id", + "formsemestre_id", + "module_id", + "moduleimpl_id", + "partition_id", + "ue_id", + }: + raise ScoValueError("page introuvable (id invalide)") from exc + pos_arg_values.append(v) # current_app.logger.info("pos_arg_values=%s" % pos_arg_values) # current_app.logger.info("req_args=%s" % req_args) # Add keyword arguments diff --git a/app/forms/main/config_apo.py b/app/forms/main/config_apo.py index f607822c1..34e04d8ea 100644 --- a/app/forms/main/config_apo.py +++ b/app/forms/main/config_apo.py @@ -63,6 +63,7 @@ class CodesDecisionsForm(FlaskForm): ABL = _build_code_field("ABL") ADC = _build_code_field("ADC") ADJ = _build_code_field("ADJ") + ADJR = _build_code_field("ADJR") ADM = _build_code_field("ADM") AJ = _build_code_field("AJ") ATB = _build_code_field("ATB") diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index 647e6ee89..425ff1923 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -94,9 +94,10 @@ class ApcReferentielCompetences(db.Model, XMLModel): return "" return self.version_orebut.split()[0] - def to_dict(self): + def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True): """Représentation complète du ref. de comp. comme un dict. + Si parcours est une liste de parcours, restreint l'export aux parcours listés. """ return { "dept_id": self.dept_id, @@ -111,8 +112,14 @@ class ApcReferentielCompetences(db.Model, XMLModel): if self.scodoc_date_loaded else "", "scodoc_orig_filename": self.scodoc_orig_filename, - "competences": {x.titre: x.to_dict() for x in self.competences}, - "parcours": {x.code: x.to_dict() for x in self.parcours}, + "competences": { + x.titre: x.to_dict(with_app_critiques=with_app_critiques) + for x in self.competences + }, + "parcours": { + x.code: x.to_dict() + for x in (self.parcours if parcours is None else parcours) + }, } def get_niveaux_by_parcours( @@ -174,6 +181,27 @@ class ApcReferentielCompetences(db.Model, XMLModel): niveaux_by_parcours_no_tc["TC"] = niveaux_tc return parcours, niveaux_by_parcours_no_tc + def get_competences_tronc_commun(self) -> list["ApcCompetence"]: + """Liste des compétences communes à tous les parcours du référentiel.""" + parcours = self.parcours.all() + if not parcours: + return [] + + ids = set.intersection( + *[ + {competence.id for competence in parcour.query_competences()} + for parcour in parcours + ] + ) + return sorted( + [ + competence + for competence in parcours[0].query_competences() + if competence.id in ids + ], + key=lambda c: c.numero or 0, + ) + class ApcCompetence(db.Model, XMLModel): "Compétence" @@ -215,7 +243,7 @@ class ApcCompetence(db.Model, XMLModel): def __repr__(self): return f"" - def to_dict(self): + def to_dict(self, with_app_critiques=True): "repr dict recursive sur situations, composantes, niveaux" return { "id_orebut": self.id_orebut, @@ -227,7 +255,10 @@ class ApcCompetence(db.Model, XMLModel): "composantes_essentielles": [ x.to_dict() for x in self.composantes_essentielles ], - "niveaux": {x.annee: x.to_dict() for x in self.niveaux}, + "niveaux": { + x.annee: x.to_dict(with_app_critiques=with_app_critiques) + for x in self.niveaux + }, } def to_dict_bul(self) -> dict: @@ -293,13 +324,15 @@ class ApcNiveau(db.Model, XMLModel): return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={ self.annee!r} {self.competence!r}>""" - def to_dict(self): - "as a dict, recursif sur les AC" + def to_dict(self, with_app_critiques=True): + "as a dict, recursif (ou non) sur les AC" return { "libelle": self.libelle, "annee": self.annee, "ordre": self.ordre, - "app_critiques": {x.code: x.to_dict() for x in self.app_critiques}, + "app_critiques": {x.code: x.to_dict() for x in self.app_critiques} + if with_app_critiques + else {}, } def to_dict_bul(self): @@ -471,6 +504,14 @@ class ApcParcours(db.Model, XMLModel): d["annees"] = {x.ordre: x.to_dict() for x in self.annees} return d + def query_competences(self) -> flask_sqlalchemy.BaseQuery: + "Les compétences associées à ce parcours" + return ( + ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours) + .filter_by(parcours_id=self.id) + .order_by(ApcCompetence.numero) + ) + class ApcAnneeParcours(db.Model, XMLModel): id = db.Column(db.Integer, primary_key=True) diff --git a/app/models/but_validations.py b/app/models/but_validations.py index 52826003e..ccef89cd4 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -71,11 +71,22 @@ class ApcValidationRCUE(db.Model): enregistrée le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}""" + def annee(self) -> str: + """l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """ + niveau = self.niveau() + return niveau.annee if niveau else None + def niveau(self) -> ApcNiveau: """Le niveau de compétence associé à cet RCUE.""" # Par convention, il est donné par la seconde UE return self.ue2.niveau_competence + def to_dict(self): + "as a dict" + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + return d + def to_dict_bul(self) -> dict: "Export dict pour bulletins: le code et le niveau de compétence" niveau = self.niveau() diff --git a/app/models/formations.py b/app/models/formations.py index 36e356472..986ef7e74 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -55,7 +55,8 @@ class Formation(db.Model): modules = db.relationship("Module", lazy="dynamic", backref="formation") def __repr__(self): - return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>" + 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: "titre complet pour affichage" diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 3c457d980..bafad116e 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -63,51 +63,51 @@ class FormSemestre(db.Model): "False si verrouillé" modalite = db.Column( db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite") - ) # "FI", "FAP", "FC", ... - # gestion compensation sem DUT: + ) + "Modalité de formation: 'FI', 'FAP', 'FC', ..." gestion_compensation = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) - # ne publie pas le bulletin XML ou JSON: + "gestion compensation sem DUT (inutilisé en APC)" bul_hide_xml = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) - # Bloque le calcul des moyennes (générale et d'UE) + "ne publie pas le bulletin XML ou JSON" block_moyennes = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) - # Bloque le calcul de la moyenne générale (utile pour BUT) + "Bloque le calcul des moyennes (générale et d'UE)" block_moyenne_generale = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) "Si vrai, la moyenne générale indicative BUT n'est pas calculée" - # semestres decales (pour gestion jurys): gestion_semestrielle = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) - # couleur fond bulletins HTML: + "Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)" bul_bgcolor = db.Column( db.String(SHORT_STR_LEN), default="white", server_default="white", nullable=False, ) - # autorise resp. a modifier semestre: + "couleur fond bulletins HTML" resp_can_edit = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) - # autorise resp. a modifier slt les enseignants: + "autorise resp. à modifier le formsemestre" resp_can_change_ens = db.Column( db.Boolean(), nullable=False, default=True, server_default="true" ) - # autorise les ens a creer des evals: + "autorise resp. a modifier slt les enseignants" ens_can_edit_eval = db.Column( db.Boolean(), nullable=False, default=False, server_default="False" ) - # code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...' + "autorise les enseignants à créer des évals dans leurs modimpls" elt_sem_apo = db.Column(db.Text()) # peut être fort long ! - # code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...' + "code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'" elt_annee_apo = db.Column(db.Text()) + "code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'" # Relations: etapes = db.relationship( diff --git a/app/models/groups.py b/app/models/groups.py index 4fd25a940..6fafa234b 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -87,6 +87,7 @@ class Partition(db.Model): def to_dict(self, with_groups=False) -> dict: """as a dict, with or without groups""" d = dict(self.__dict__) + d["partition_id"] = self.id d.pop("_sa_instance_state", None) d.pop("formsemestre", None) diff --git a/app/models/ues.py b/app/models/ues.py index 387fc8d28..596e0bef6 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -111,6 +111,7 @@ class UniteEns(db.Model): e["ects"] = e["ects"] e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["code_apogee"] = e["code_apogee"] or "" # pas de None + e["parcour"] = self.parcour.to_dict() if self.parcour else None if with_module_ue_coefs: if convert_objects: e["module_ue_coefs"] = [ @@ -219,6 +220,8 @@ class UniteEns(db.Model): db.session.add(self) db.session.commit() + # Invalidation du cache + self.formation.invalidate_cached_sems() log(f"ue.set_niveau_competence( {self}, {niveau} )") def set_parcour(self, parcour: ApcParcours): @@ -246,6 +249,8 @@ class UniteEns(db.Model): self.niveau_competence = None db.session.add(self) db.session.commit() + # Invalidation du cache + self.formation.invalidate_cached_sems() log(f"ue.set_parcour( {self}, {parcour} )") diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py index 7b0c79970..502027529 100644 --- a/app/pe/pe_jurype.py +++ b/app/pe/pe_jurype.py @@ -459,8 +459,7 @@ class JuryPE(object): etud = self.get_cache_etudInfo_d_un_etudiant(etudid) (_, parcours) = sco_report.get_codeparcoursetud(etud) if ( - len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values())) - > 0 + len(sco_codes_parcours.CODES_SEM_REO & set(parcours.values())) > 0 ): # Eliminé car NAR apparait dans le parcours reponse = True if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: @@ -563,9 +562,8 @@ class JuryPE(object): dec = nt.get_etud_decision_sem( etudid ) # quelle est la décision du jury ? - if dec and dec["code"] in list( - sco_codes_parcours.CODES_SEM_VALIDES.keys() - ): # isinstance( sesMoyennes[i+1], float) and + if dec and (dec["code"] in sco_codes_parcours.CODES_SEM_VALIDES): + # isinstance( sesMoyennes[i+1], float) and # mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide" leFid = sem["formsemestre_id"] else: diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 8e2f29145..e47177d64 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -1084,7 +1084,7 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr): recipients = [recipient_addr] sender = sco_preferences.get_preference("email_from_addr", formsemestre_id) if copy_addr: - bcc = copy_addr.strip() + bcc = copy_addr.strip().split(",") else: bcc = "" @@ -1094,7 +1094,7 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr): subject, sender, recipients, - bcc=[bcc], + bcc=bcc, text_body=hea, attachments=[ {"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata} diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py index 21b2b0c87..df5484fb0 100644 --- a/app/scodoc/sco_codes_parcours.py +++ b/app/scodoc/sco_codes_parcours.py @@ -187,16 +187,23 @@ CODES_EXPL = { # Les codes de semestres: CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT} -CODES_SEM_VALIDES = {ADM: True, ADC: True, ADJ: True} # semestre validé -CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente +CODES_SEM_VALIDES_DE_DROIT = {ADM, ADC} +CODES_SEM_VALIDES = CODES_SEM_VALIDES_DE_DROIT | {ADJ} # semestre validé +CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente -CODES_SEM_REO = {NAR: 1} # reorientation +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} +"UE validée" + +CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP} +CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ} +"Niveau RCUE validé" -CODES_UE_VALIDES = {ADM: True, CMP: True, ADJ: True, ADJR: True} # UE validée -CODES_RCUE_VALIDES = CODES_UE_VALIDES # Niveau RCUE validé # Pour le BUT: +CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD} CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL} -CODES_RCUE = {ADM, AJ, CMP} BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE @@ -226,17 +233,17 @@ BUT_CODES_ORDERED = { def code_semestre_validant(code: str) -> bool: "Vrai si ce CODE entraine la validation du semestre" - return CODES_SEM_VALIDES.get(code, False) + return code in CODES_SEM_VALIDES def code_semestre_attente(code: str) -> bool: "Vrai si ce CODE est un code d'attente (semestre validable plus tard par jury ou compensation)" - return CODES_SEM_ATTENTES.get(code, False) + return code in CODES_SEM_ATTENTES def code_ue_validant(code: str) -> bool: "Vrai si ce code d'UE est validant (ie attribue les ECTS)" - return CODES_UE_VALIDES.get(code, False) + return code in CODES_UE_VALIDES DEVENIR_EXPL = { diff --git a/app/scodoc/sco_cursus_dut.py b/app/scodoc/sco_cursus_dut.py index 3474081ff..0549f6677 100644 --- a/app/scodoc/sco_cursus_dut.py +++ b/app/scodoc/sco_cursus_dut.py @@ -890,7 +890,7 @@ def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite) car ils ne dépendent que de la note d'UE et de la validation ou non du semestre. Les UE des semestres NON ASSIDUS ne sont jamais validées (code AJ). """ - valid_semestre = CODES_SEM_VALIDES.get(code_etat_sem, False) + valid_semestre = code_etat_sem in CODES_SEM_VALIDES cnx = ndb.GetDBConnexion() formsemestre = FormSemestre.query.get_or_404(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index 1bea3b71f..b904bd5b5 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -99,7 +99,7 @@ def html_edit_formation_apc( H = [ render_template( - "pn/form_ues.html", + "pn/form_ues.j2", formation=formation, semestre_ids=semestre_ids, editable=editable, @@ -122,7 +122,7 @@ def html_edit_formation_apc( ).first() H += [ render_template( - "pn/form_mods.html", + "pn/form_mods.j2", formation=formation, titre=f"Ressources du S{semestre_idx}", create_element_msg="créer une nouvelle ressource", @@ -138,7 +138,7 @@ def html_edit_formation_apc( if ues_by_sem[semestre_idx].count() > 0 else "", render_template( - "pn/form_mods.html", + "pn/form_mods.j2", formation=formation, titre=f"Situations d'Apprentissage et d'Évaluation (SAÉs) S{semestre_idx}", create_element_msg="créer une nouvelle SAÉ", @@ -154,7 +154,7 @@ def html_edit_formation_apc( if ues_by_sem[semestre_idx].count() > 0 else "", render_template( - "pn/form_mods.html", + "pn/form_mods.j2", formation=formation, titre=f"Autres modules (non BUT) du S{semestre_idx}", create_element_msg="créer un nouveau module", @@ -196,7 +196,7 @@ def html_ue_infos(ue): and ue.matieres.count() == 0 ) return render_template( - "pn/ue_infos.html", + "pn/ue_infos.j2", titre=f"UE {ue.acronyme} {ue.titre}", ue=ue, formsemestres=formsemestres, diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 5d269875e..906493a2a 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -723,7 +723,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list "libjs/jQuery-tagEditor/jquery.caret.min.js", "js/module_tag_editor.js", ], - page_title=f"Programme {formation.acronyme}", + page_title=f"Programme {formation.acronyme} v{formation.version}", ), f"""

{formation.to_html()} {lockicon}

@@ -765,7 +765,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours); # Description de la formation H.append( render_template( - "pn/form_descr.html", + "pn/form_descr.j2", formation=formation, parcours=parcours, editable=editable, @@ -913,8 +913,12 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
  • Export XML de la formation - (permet de la sauvegarder pour l'échanger avec un autre site) + }">Export XML de la formation ou + sans codes Apogée + (permet de l'enregistrer pour l'échanger avec un autre site)
  • Code semestre: