diff --git a/app/api/formations.py b/app/api/formations.py index c784f1100..4eec86698 100644 --- a/app/api/formations.py +++ b/app/api/formations.py @@ -8,17 +8,17 @@ ScoDoc 9 API : accès aux formations """ -from flask import g, jsonify +from flask import g, jsonify, request from flask_login import login_required import app +from app import log from app.api import api_bp as bp, api_web_bp from app.scodoc.sco_utils import json_error from app.decorators import scodoc, permission_required -from app.models.formations import Formation -from app.models.formsemestre import FormSemestre -from app.models.moduleimpls import ModuleImpl +from app.models import ApcParcours, Formation, FormSemestre, ModuleImpl, UniteEns from app.scodoc import sco_formations +from app.scodoc.sco_exceptions import ScoFormationConflict from app.scodoc.sco_permissions import Permission @@ -174,7 +174,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False): ] }, { - "titre": "Se sensibiliser \u00e0 l'hygi\u00e8ne informatique et \u00e0 la cybers\u00e9curit\u00e9", + "titre": "Se sensibiliser \u00e0 l'hygi\u00e8ne informatique...", "abbrev": "Hygi\u00e8ne informatique", "code": "SAE11", "heures_cours": 0.0, @@ -282,3 +282,29 @@ def moduleimpl(moduleimpl_id: int): query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) modimpl: ModuleImpl = query.first_or_404() return jsonify(modimpl.to_dict(convert_objects=True)) + + +@bp.route("/set_ue_parcours/", methods=["POST"]) +@api_web_bp.route("/set_ue_parcours/", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoChangeFormation) +def set_ue_parcours(ue_id: int): + """Associe UE et parcours BUT. + La liste des ids de parcours est passée en argument JSON. + JSON arg: [parcour_id1, parcour_id2, ...] + """ + query = UniteEns.query.filter_by(id=ue_id) + if g.scodoc_dept: + query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id) + ue: UniteEns = query.first_or_404() + parcours_ids = request.get_json(force=True) or [] # may raise 400 Bad Request + if parcours_ids == [""]: + parcours = [] + else: + parcours = [ + ApcParcours.query.get_or_404(int(parcour_id)) for parcour_id in parcours_ids + ] + log(f"set_ue_parcours: ue_id={ue.id} parcours_ids={parcours_ids}") + ok, error_message = ue.set_parcours(parcours) + return jsonify({"status": ok, "message": error_message}) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index b7673ffe7..6f0ff414a 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -7,6 +7,8 @@ """ ScoDoc 9 API : accès aux formsemestres """ +from operator import attrgetter, itemgetter + from flask import g, jsonify, request from flask_login import login_required @@ -254,7 +256,7 @@ def formsemestre_programme(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) - ues = formsemestre.query_ues() + ues = formsemestre.get_ues() m_list = { ModuleType.RESSOURCE: [], ModuleType.SAE: [], @@ -345,7 +347,7 @@ def formsemestre_etudiants( etud["id"], formsemestre_id, exclude_default=True ) - return jsonify(sorted(etuds, key=lambda e: e["sort_key"])) + return jsonify(sorted(etuds, key=itemgetter("sort_key"))) @bp.route("/formsemestre//etat_evals") @@ -432,7 +434,7 @@ def etat_evals(formsemestre_id: int): # Si il y a plus d'une note saisie pour l'évaluation if len(notes) >= 1: # Tri des notes en fonction de leurs dates - notes_sorted = sorted(notes, key=lambda note: note.date) + notes_sorted = sorted(notes, key=attrgetter("date")) date_debut = notes_sorted[0].date date_fin = notes_sorted[-1].date diff --git a/app/api/partitions.py b/app/api/partitions.py index 8a2d97829..6ca3e4552 100644 --- a/app/api/partitions.py +++ b/app/api/partitions.py @@ -7,6 +7,8 @@ """ ScoDoc 9 API : partitions """ +from operator import attrgetter + from flask import g, jsonify, request from flask_login import login_required @@ -85,7 +87,7 @@ def formsemestre_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) - partitions = sorted(formsemestre.partitions, key=lambda p: p.numero or 0) + partitions = sorted(formsemestre.partitions, key=attrgetter("numero")) return jsonify( { partition.id: partition.to_dict(with_groups=True) @@ -441,9 +443,9 @@ def formsemestre_order_partitions(formsemestre_id: int): message="paramètre liste des partitions invalide", ) for p_id, numero in zip(partition_ids, range(len(partition_ids))): - p = Partition.query.get_or_404(p_id) - p.numero = numero - db.session.add(p) + partition = Partition.query.get_or_404(p_id) + partition.numero = numero + db.session.add(partition) db.session.commit() app.set_sco_dept(formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(formsemestre_id) diff --git a/app/but/apc_edit_ue.py b/app/but/apc_edit_ue.py index 1e3fbce28..c68d98f6f 100644 --- a/app/but/apc_edit_ue.py +++ b/app/but/apc_edit_ue.py @@ -7,9 +7,10 @@ """ Edition associations UE <-> Ref. Compétence """ -from flask import g, url_for +from flask import g, render_template, url_for from app.models import ApcReferentielCompetences, UniteEns from app.scodoc import codes_cursus +from app.forms.formation.ue_parcours_niveau import UEParcoursNiveauForm def form_ue_choix_niveau(ue: UniteEns) -> str: @@ -32,7 +33,7 @@ def form_ue_choix_niveau(ue: UniteEns) -> str: for parcour in ref_comp.parcours: parcours_options.append( f"""""" ) @@ -44,14 +45,14 @@ def form_ue_choix_niveau(ue: UniteEns) -> str:
Parcours : - @@ -72,6 +73,28 @@ def form_ue_choix_niveau(ue: UniteEns) -> str: """ +# Nouvelle version XXX WIP +def form_ue_choix_parcours_niveau(ue: UniteEns): + """formulaire (div) pour choix association des parcours et du niveau de compétence d'une UE""" + if ue.type != codes_cursus.UE_STANDARD: + return "" + ref_comp = ue.formation.referentiel_competence + if ref_comp is None: + return f"""
+
Pas de référentiel de compétence associé à cette formation !
+ +
""" + parcours = ue.formation.referentiel_competence.parcours + form = UEParcoursNiveauForm(ue, parcours) + return f"""
+ { render_template( "pn/ue_choix_parcours_niveau.j2", form_ue_parcours_niveau=form ) } +
+ """ + + def get_ue_niveaux_options_html(ue: UniteEns) -> str: """fragment html avec les options du menu de sélection du niveau de compétences associé à une UE. @@ -85,9 +108,7 @@ def get_ue_niveaux_options_html(ue: UniteEns) -> str: return "" # Les niveaux: annee = ue.annee() # 1, 2, 3 - parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours( - annee, parcour=ue.parcour - ) + parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee, ue.parcours) # Les niveaux déjà associés à d'autres UE du même semestre autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx) diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 226bf1dda..108d80299 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -24,7 +24,6 @@ from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_compat import NotesTableCompat from app.comp import res_sem -from app.models import formsemestre from app.models.but_refcomp import ( ApcAnneeParcours, @@ -32,6 +31,7 @@ from app.models.but_refcomp import ( ApcNiveau, ApcParcours, ApcParcoursNiveauCompetence, + ApcReferentielCompetences, ) from app.models import Scolog, ScolarAutorisationInscription from app.models.but_validations import ( @@ -109,7 +109,7 @@ class EtudCursusBUT: "cache les niveaux" for annee in (1, 2, 3): niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours( - annee, self.parcour + annee, [self.parcour] )[1] # groupe les niveaux de tronc commun et ceux spécifiques au parcour self.niveaux_by_annee[annee] = niveaux_d["TC"] + ( @@ -170,6 +170,7 @@ class EtudCursusBUT: } } """ + # XXX lent, provisoirement utilisé par TableJury.add_but_competences() return { competence.id: { annee: self.validation_par_competence_et_annee.get( @@ -185,7 +186,7 @@ class EtudCursusBUT: """ { competence_id : { - annee : { validation} + annee : { validation } } } où validation est un petit dict avec niveau_id, etc. @@ -204,3 +205,210 @@ class EtudCursusBUT: validation_rcue.to_dict_codes() if validation_rcue else None ) return d + + +class FormSemestreCursusBUT: + """L'état des étudiants d'un formsemestre dans leur cursus BUT + Permet d'obtenir pour chacun liste des niveaux validés/à valider + """ + + def __init__(self, res: ResultatsSemestreBUT): + """res indique le formsemestre de référence, + qui donne la liste des étudiants et le référentiel de compétence. + """ + self.res = res + self.formsemestre = res.formsemestre + if not res.formsemestre.formation.referentiel_competence: + raise ScoNoReferentielCompetences(formation=res.formsemestre.formation) + # Données cachées pour accélerer les accès: + self.referentiel_competences_id: int = ( + self.res.formsemestre.formation.referentiel_competence_id + ) + self.ue_ids: set[int] = set() + "set of ue_ids known to belong to our cursus" + self.parcours_by_id: dict[int, ApcParcours] = {} + "cache des parcours" + self.niveaux_by_parcour_by_annee: dict[int, dict[int, list[ApcNiveau]]] = {} + "cache { parcour_id : { annee : [ parcour] } }" + self.niveaux_by_id: dict[int, ApcNiveau] = {} + "cache niveaux" + + def get_niveaux_parcours_etud(self, etud: Identite) -> dict[int, list[ApcNiveau]]: + """Les niveaux compétences que doit valider cet étudiant. + Le parcour considéré est celui de l'inscription dans le semestre courant. + Si on est en début de cursus, on peut être en tronc commun sans avoir choisi + de parcours. Dans ce cas, on n'aura que les compétences de tronc commun. + Il faudra donc, avant de diplômer, s'assurer que les compétences du parcours + du dernier semestre (S6) sont validées (avec parcour non NULL). + """ + parcour_id = self.res.etuds_parcour_id.get(etud.id) + if parcour_id is None: + parcour = None + else: + if parcour_id not in self.parcours_by_id: + self.parcours_by_id[parcour_id] = ApcParcours.query.get(parcour_id) + parcour = self.parcours_by_id[parcour_id] + + return self.get_niveaux_parcours_by_annee(parcour) + + def get_niveaux_parcours_by_annee( + self, parcour: ApcParcours + ) -> dict[int, list[ApcNiveau]]: + """La liste des niveaux de compétences du parcours, par année BUT. + { 1 : [ niveau, ... ] } + Si parcour est None, donne uniquement les niveaux tronc commun + (cas utile par exemple en 1ere année, mais surtout pas pour donner un diplôme!) + """ + parcour_id = None if parcour is None else parcour.id + if parcour_id in self.niveaux_by_parcour_by_annee: + return self.niveaux_by_parcour_by_annee[parcour_id] + + ref_comp: ApcReferentielCompetences = ( + self.res.formsemestre.formation.referentiel_competence + ) + niveaux_by_annee = {} + for annee in (1, 2, 3): + niveaux_d = ref_comp.get_niveaux_by_parcours(annee, [parcour])[1] + # groupe les niveaux de tronc commun et ceux spécifiques au parcour + niveaux_by_annee[annee] = niveaux_d["TC"] + ( + niveaux_d[parcour.id] if parcour else [] + ) + self.niveaux_by_parcour_by_annee[parcour_id] = niveaux_by_annee + self.niveaux_by_id.update( + {niveau.id: niveau for niveau in niveaux_by_annee[annee]} + ) + return niveaux_by_annee + + def get_etud_validation_par_competence_et_annee(self, etud: Identite): + """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" + validation_par_competence_et_annee = {} + for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): + # On s'assurer qu'elle concerne notre cursus ! + ue = validation_rcue.ue2 + if ue.id not in self.ue_ids: + if ( + ue.formation.referentiel_competences_id + == self.referentiel_competences_id + ): + self.ue_ids = ue.id + else: + continue # skip this validation + niveau = validation_rcue.niveau() + if not niveau.competence.id in validation_par_competence_et_annee: + validation_par_competence_et_annee[niveau.competence.id] = {} + previous_validation = 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 + return validation_par_competence_et_annee + + def list_etud_inscriptions(self, etud: Identite): + + "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]} + ) + + self.validation_par_competence_et_annee = {} + """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" + 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 formsemestre_warning_apc_setup( + formsemestre: FormSemestre, res: ResultatsSemestreBUT +) -> str: + """Vérifie que la formation est OK pour un BUT: + - ref. compétence associé + - tous les niveaux des parcours du semestre associés à des UEs du formsemestre + - pas d'UE non associée à un niveau + Renvoie fragment de HTML. + """ + if not formsemestre.formation.is_apc(): + return "" + if formsemestre.formation.referentiel_competence is None: + return f""" + """ + # Vérifie les niveaux de chaque parcours + H = [] + for parcour in formsemestre.parcours or [None]: + annee = (formsemestre.semestre_id + 1) // 2 + niveaux_ids = { + niveau.id + for niveau in ApcNiveau.niveaux_annee_de_parcours( + parcour, annee, formsemestre.formation.referentiel_competence + ) + } + ues_parcour = formsemestre.formation.query_ues_parcour(parcour).filter( + UniteEns.semestre_idx == formsemestre.semestre_id + ) + ues_niveaux_ids = { + ue.niveau_competence.id for ue in ues_parcour if ue.niveau_competence + } + if niveaux_ids != ues_niveaux_ids: + H.append( + f"""Parcours {parcour.code if parcour else "Tronc commun"} : + {len(ues_niveaux_ids)} UE avec niveaux + mais {len(niveaux_ids)} niveaux à valider ! + """ + ) + if not H: + return "" + return f"""
+ Problème dans la configuration de la formation: +
    +
  • { '
  • '.join(H) } +
+

Vérifiez les parcours cochés pour ce semestre, + et les associations entre UE et niveaux dans la formation. +

+
+ """ diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 1e861c9e5..16a485b57 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -324,7 +324,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): parcours, niveaux_by_parcours, ) = formation.referentiel_competence.get_niveaux_by_parcours( - self.annee_but, self.parcour + self.annee_but, [self.parcour] ) self.niveaux_competences = niveaux_by_parcours["TC"] + ( niveaux_by_parcours[self.parcour.id] if self.parcour else [] @@ -1003,7 +1003,7 @@ def list_ue_parcour_etud( parcour = ApcParcours.query.get(res.etuds_parcour_id[etud.id]) ues = ( formsemestre.formation.query_ues_parcour(parcour) - .filter_by(semestre_idx=formsemestre.semestre_id) + .filter(UniteEns.semestre_idx == formsemestre.semestre_id) .order_by(UniteEns.numero) .all() ) diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index 45c0cbb8b..d9ca76bbb 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -228,14 +228,14 @@ class BonusSportAdditif(BonusSport): # en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus) if self.formsemestre.formation.is_apc(): # Bonus sur les UE et None sur moyenne générale - ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] + ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)] self.bonus_ues = pd.DataFrame( bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float ) elif self.classic_use_bonus_ues: # Formations classiques apppliquant le bonus sur les UEs # ici bonus_moy_arr = ndarray 1d nb_etuds - ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] + ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)] self.bonus_ues = pd.DataFrame( np.stack([bonus_moy_arr] * len(ues_idx)).T, index=self.etuds_idx, @@ -420,7 +420,7 @@ class BonusAmiens(BonusSportAdditif): # # Bonus moyenne générale et sur les UE # self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float) -# ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] +# ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)] # nb_ues_no_bonus = len(ues_idx) # self.bonus_ues = pd.DataFrame( # np.stack([bonus] * nb_ues_no_bonus, axis=1), @@ -597,7 +597,7 @@ class BonusCachan1(BonusSportAdditif): sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds - ues = self.formsemestre.query_ues(with_sport=False).all() + ues = self.formsemestre.get_ues(with_sport=False) ues_idx = [ue.id for ue in ues] if self.formsemestre.formation.is_apc(): # --- BUT @@ -687,7 +687,7 @@ class BonusCalais(BonusSportAdditif): else: self.classic_use_bonus_ues = True # pour les LP super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) - ues = self.formsemestre.query_ues(with_sport=False).all() + ues = self.formsemestre.get_ues(with_sport=False) ues_sans_bs = [ ue for ue in ues if ue.acronyme[-2:].upper() != "BS" ] # les 2 derniers cars forcés en majus @@ -788,7 +788,7 @@ class BonusIUTRennes1(BonusSportAdditif): sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds - nb_ues = self.formsemestre.query_ues(with_sport=False).count() + nb_ues = len(self.formsemestre.get_ues(with_sport=False)) bonus_moy_arr = np.where( note_bonus_max > self.seuil_moy_gen, diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index cf7b8fe82..d86eccbf5 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -415,7 +415,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: """ modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() - ues = modimpl.formsemestre.query_ues(with_sport=False).all() + ues = modimpl.formsemestre.get_ues(with_sport=False) ue_ids = [ue.id for ue in ues] evaluation_ids = [evaluation.id for evaluation in evaluations] evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index bf1586693..5d6e27b4e 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -121,7 +121,7 @@ def df_load_modimpl_coefs( DataFrame rows = UEs (sans bonus), columns = modimpl, value = coef. """ if ues is None: - ues = formsemestre.query_ues().all() + ues = formsemestre.get_ues() ue_ids = [x.id for x in ues] if modimpls is None: modimpls = formsemestre.modimpls_sorted diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 00fd9fb76..71bd71a8a 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -247,9 +247,9 @@ class ResultatsSemestreBUT(NotesTableCompat): ) in self.formsemestre.formation.referentiel_competence.parcours.all() + [None]: ue_by_parcours[None if parcour is None else parcour.id] = { ue.id: 1.0 - for ue in self.formsemestre.formation.query_ues_parcour( - parcour - ).filter_by(semestre_idx=self.formsemestre.semestre_id) + for ue in self.formsemestre.formation.query_ues_parcour(parcour).filter( + UniteEns.semestre_idx == self.formsemestre.semestre_id + ) } # for etudid in etuds_parcour_id: @@ -290,7 +290,7 @@ class ResultatsSemestreBUT(NotesTableCompat): ues_parcour = self.formsemestre.formation.query_ues_parcour(parcour) ues_ids = set() for niveau in niveaux: - ue = ues_parcour.filter_by(niveau_competence=niveau).first() + ue = ues_parcour.filter_by(UniteEns.niveau_competence == niveau).first() if ue: ues_ids.add(ue.id) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 850d02f32..e59172170 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -10,6 +10,8 @@ from collections import Counter, defaultdict from collections.abc import Generator from functools import cached_property +from operator import attrgetter + import numpy as np import pandas as pd @@ -162,7 +164,7 @@ class ResultatsSemestre(ResultatsCache): (indices des DataFrames). Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs. """ - return self.formsemestre.query_ues(with_sport=True).all() + return self.formsemestre.get_ues(with_sport=True) @cached_property def ressources(self): @@ -233,7 +235,7 @@ class ResultatsSemestre(ResultatsCache): for modimpl in self.formsemestre.modimpls_sorted if self.modimpl_inscr_df[modimpl.id][etudid] } - ues = sorted(list(ues), key=lambda x: x.numero or 0) + ues = sorted(list(ues), key=attrgetter("numero")) return ues def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]: @@ -283,7 +285,7 @@ class ResultatsSemestre(ResultatsCache): # Quand il y a une capitalisation, vérifie toutes les UEs sum_notes_ue = 0.0 sum_coefs_ue = 0.0 - for ue in self.formsemestre.query_ues(): + for ue in self.formsemestre.get_ues(): ue_cap = self.get_etud_ue_status(etudid, ue.id) if ue_cap is None: continue diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py index bc501b64a..05f52905c 100644 --- a/app/comp/res_compat.py +++ b/app/comp/res_compat.py @@ -108,7 +108,7 @@ class NotesTableCompat(ResultatsSemestre): Si filter_sport, retire les UE de type SPORT. Résultat: liste de dicts { champs UE U stats moyenne UE } """ - ues = self.formsemestre.query_ues(with_sport=not filter_sport) + ues = self.formsemestre.get_ues(with_sport=not filter_sport) ues_dict = [] for ue in ues: d = ue.to_dict() @@ -178,7 +178,7 @@ class NotesTableCompat(ResultatsSemestre): self.etud_moy_gen_ranks, self.etud_moy_gen_ranks_int, ) = moy_sem.comp_ranks_series(etud_moy_gen_dem_zero) - ues = self.formsemestre.query_ues() + ues = self.formsemestre.get_ues() for ue in ues: moy_ue = self.etud_moy_ue[ue.id] self.ue_rangs[ue.id] = ( @@ -260,7 +260,7 @@ class NotesTableCompat(ResultatsSemestre): Return: True|False, message explicatif """ ue_status_list = [] - for ue in self.formsemestre.query_ues(): + for ue in self.formsemestre.get_ues(): ue_status = self.get_etud_ue_status(etudid, ue.id) if ue_status: ue_status_list.append(ue_status) @@ -477,7 +477,7 @@ class NotesTableCompat(ResultatsSemestre): """ table_moyennes = [] etuds_inscriptions = self.formsemestre.etuds_inscriptions - ues = self.formsemestre.query_ues(with_sport=True) # avec bonus + ues = self.formsemestre.get_ues(with_sport=True) # avec bonus for etudid in etuds_inscriptions: moy_gen = self.etud_moy_gen.get(etudid, False) if moy_gen is False: diff --git a/app/forms/formation/ue_parcours_niveau.py b/app/forms/formation/ue_parcours_niveau.py new file mode 100644 index 000000000..d9070c280 --- /dev/null +++ b/app/forms/formation/ue_parcours_niveau.py @@ -0,0 +1,39 @@ +from flask import g, url_for +from flask_wtf import FlaskForm +from wtforms.fields import SelectField, SelectMultipleField + +from app.models import ApcParcours, ApcReferentielCompetences, UniteEns + + +class UEParcoursNiveauForm(FlaskForm): + "Formulaire association parcours et niveau de compétence à une UE" + niveau_select = SelectField( + "Niveau de compétence:", render_kw={"class": "niveau_select"} + ) + parcours_multiselect = SelectMultipleField( + "Parcours :", + coerce=int, + option_widget={"class": "form-check-input"}, + # widget_attrs={"class": "form-check"}, + render_kw={"class": "multiselect select_ue_parcours", "multiple": "multiple"}, + ) + + def __init__(self, ue: UniteEns, parcours: list[ApcParcours], *args, **kwargs): + super().__init__(*args, **kwargs) + + # Initialise le menu des niveaux: + self.niveau_select.render_kw["data-ue_id"] = ue.id + self.niveau_select.choices = [ + (r.id, f"{r.type_titre} {r.specialite_long} ({r.get_version()})") + for r in ApcReferentielCompetences.query.filter_by(dept_id=g.scodoc_dept_id) + ] + # Initialise le menu des parcours + self.parcours_multiselect.render_kw["data-set_ue_parcours"] = url_for( + "apiweb.set_ue_parcours", ue_id=ue.id, scodoc_dept=g.scodoc_dept + ) + parcours_options = [(str(p.id), f"{p.libelle} ({p.code})") for p in parcours] + self.parcours_multiselect.choices = parcours_options + + # initialize checked items based on u instance + parcours_selected = [str(p.id) for p in ue.parcours] + self.parcours_multiselect.process_data(parcours_selected) diff --git a/app/forms/formsemestre/change_formation.py b/app/forms/formsemestre/change_formation.py index 4becfea5e..9161c93a5 100644 --- a/app/forms/formsemestre/change_formation.py +++ b/app/forms/formsemestre/change_formation.py @@ -29,14 +29,14 @@ Formulaire changement formation """ from flask_wtf import FlaskForm -from wtforms import RadioField, SubmitField, validators +from wtforms import RadioField, SubmitField from app.models import Formation class FormSemestreChangeFormationForm(FlaskForm): "Formulaire changement formation d'un formsemestre" - # consrtuit dynamiquement ci-dessous + # construit dynamiquement ci-dessous def gen_formsemestre_change_formation_form( diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index edeb6ee28..3a17cfc03 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -6,6 +6,7 @@ """ScoDoc 9 models : Référentiel Compétence BUT 2021 """ from datetime import datetime +from operator import attrgetter from flask_sqlalchemy.query import Query from sqlalchemy.orm import class_mapper @@ -129,11 +130,11 @@ class ApcReferentielCompetences(db.Model, XMLModel): } def get_niveaux_by_parcours( - self, annee: int, parcour: "ApcParcours" = None + self, annee: int, parcours: list["ApcParcours"] = None ) -> tuple[list["ApcParcours"], dict]: """ Construit la liste des niveaux de compétences pour chaque parcours - de ce référentiel, ou seulement pour le parcours donné. + de ce référentiel, ou seulement pour les parcours donnés. Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun. @@ -150,10 +151,8 @@ class ApcReferentielCompetences(db.Model, XMLModel): ) """ parcours_ref = self.parcours.order_by(ApcParcours.numero).all() - if parcour is None: + if parcours is None: parcours = parcours_ref - else: - parcours = [parcour] niveaux_by_parcours = { parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self) for parcour in parcours_ref @@ -205,7 +204,7 @@ class ApcReferentielCompetences(db.Model, XMLModel): for competence in parcours[0].query_competences() if competence.id in ids ], - key=lambda c: c.numero or 0, + key=attrgetter("numero"), ) def table_niveaux_parcours(self) -> dict: @@ -241,7 +240,7 @@ class ApcCompetence(db.Model, XMLModel): titre = db.Column(db.Text(), nullable=False, index=True) titre_long = db.Column(db.Text()) couleur = db.Column(db.Text()) - numero = db.Column(db.Integer) # ordre de présentation + numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation _xml_attribs = { # xml_attrib : attribute "id": "id_orebut", "nom_court": "titre", # was name @@ -524,7 +523,7 @@ class ApcParcours(db.Model, XMLModel): db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"), nullable=False, ) - numero = db.Column(db.Integer) # ordre de présentation + numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation code = db.Column(db.Text(), nullable=False) libelle = db.Column(db.Text(), nullable=False) annees = db.relationship( @@ -533,7 +532,6 @@ class ApcParcours(db.Model, XMLModel): lazy="dynamic", cascade="all, delete-orphan", ) - ues = db.relationship("UniteEns", back_populates="parcour") def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>" diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 04d0dcf91..3882ff436 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -3,6 +3,7 @@ """ScoDoc models: evaluations """ import datetime +from operator import attrgetter from app import db from app.models.etudiants import Identite @@ -44,7 +45,7 @@ class Evaluation(db.Model): ) # ordre de presentation (par défaut, le plus petit numero # est la plus ancienne eval): - numero = db.Column(db.Integer) + numero = db.Column(db.Integer, nullable=False, default=0) ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True) def __repr__(self): @@ -151,7 +152,7 @@ class Evaluation(db.Model): Return True if (uncommited) modification, False otherwise. """ ue_coef_dict = self.moduleimpl.module.get_ue_coef_dict() - sem_ues = self.moduleimpl.formsemestre.query_ues(with_sport=False).all() + sem_ues = self.moduleimpl.formsemestre.get_ues(with_sport=False) modified = False for ue in sem_ues: existing_poids = EvaluationUEPoids.query.filter_by( @@ -196,7 +197,7 @@ class Evaluation(db.Model): return { p.ue.id: p.poids for p in sorted( - self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme) + self.ue_poids, key=lambda p: attrgetter("ue.numero", "ue.acronyme") ) } diff --git a/app/models/formations.py b/app/models/formations.py index 579ad2bf7..dd809500b 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -9,13 +9,12 @@ from app.models import SHORT_STR_LEN from app.models.but_refcomp import ( ApcAnneeParcours, ApcCompetence, - ApcNiveau, ApcParcours, ApcParcoursNiveauCompetence, ) from app.models.modules import Module from app.models.moduleimpls import ModuleImpl -from app.models.ues import UniteEns +from app.models.ues import UniteEns, UEParcours from app.scodoc import sco_cache from app.scodoc import codes_cursus from app.scodoc import sco_utils as scu @@ -213,23 +212,36 @@ class Formation(db.Model): if change: app.clear_scodoc_cache() - def query_ues_parcour(self, parcour: ApcParcours) -> Query: - """Les UEs d'un parcours de la formation. + def query_ues_parcour( + self, parcour: ApcParcours, with_sport: bool = False + ) -> Query: + """Les UEs (non bonus) d'un parcours de la formation + (déclarée comme faisant partie du parcours ou du tronc commun, sans aucun parcours) Si parcour est None, les UE sans parcours. Exemple: pour avoir les UE du semestre 3, faire - `formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)` + `formation.query_ues_parcour(parcour).filter(UniteEns.semestre_idx == 3)` """ - if parcour is None: - return UniteEns.query.filter_by( - formation=self, type=UE_STANDARD, parcour_id=None - ) - return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter( - UniteEns.niveau_competence_id == ApcNiveau.id, - (UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None), - ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id, - ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, - ApcAnneeParcours.parcours_id == parcour.id, + if with_sport: + query_f = UniteEns.query.filter_by(formation=self) + else: + query_f = UniteEns.query.filter_by(formation=self, type=UE_STANDARD) + # Les UE sans parcours: + query_no_parcours = query_f.outerjoin(UEParcours).filter( + UEParcours.parcours_id == None ) + if parcour is None: + return query_no_parcours.order_by(UniteEns.numero) + # Ajoute les UE du parcours sélectionné: + return query_no_parcours.union( + query_f.join(UEParcours).filter_by(parcours_id=parcour.id) + ).order_by(UniteEns.numero) + # return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter( + # UniteEns.niveau_competence_id == ApcNiveau.id, + # (UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None), + # ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id, + # ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, + # ApcAnneeParcours.parcours_id == parcour.id, + # ) def query_competences_parcour(self, parcour: ApcParcours) -> Query: """Les ApcCompetences d'un parcours de la formation. @@ -279,7 +291,7 @@ class Matiere(db.Model): matiere_id = db.synonym("id") ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id")) titre = db.Column(db.Text()) - numero = db.Column(db.Integer) # ordre de présentation + numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation modules = db.relationship("Module", lazy="dynamic", backref="matiere") diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index c759a9b5c..ef2858baf 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -12,6 +12,7 @@ """ import datetime from functools import cached_property +from operator import attrgetter from flask_login import current_user from flask_sqlalchemy.query import Query @@ -282,60 +283,43 @@ class FormSemestre(db.Model): ) return r or [] - def query_ues(self, with_sport=False) -> Query: + def get_ues(self, with_sport=False) -> list[UniteEns]: """UE des modules de ce semestre, triées par numéro. - Formations classiques: les UEs auxquelles appartiennent les modules mis en place dans ce semestre. - Formations APC / BUT: les UEs de la formation qui - - ont le même numéro de semestre que ce formsemestre - - sont associées à l'un des parcours de ce formsemestre (ou à aucun) - + - ont le même numéro de semestre que ce formsemestre; + - et sont associées à l'un des parcours de ce formsemestre + (ou à aucun, donc tronc commun). """ - if self.formation.get_cursus().APC_SAE: - sem_ues = UniteEns.query.filter_by( - formation=self.formation, semestre_idx=self.semestre_id - ) - if self.parcours: - # Prend toutes les UE de l'un des parcours du sem., ou déclarées sans parcours - sem_ues = sem_ues.filter( - (UniteEns.parcour == None) - | (UniteEns.parcour_id.in_([p.id for p in self.parcours])) + formation: Formation = self.formation + if formation.is_apc(): + sem_ues = { + ue.id: ue + for ue in formation.query_ues_parcour( + None, with_sport=with_sport + ).filter(UniteEns.semestre_idx == self.semestre_id) + } + for parcour in self.parcours: + sem_ues.update( + { + ue.id: ue + for ue in formation.query_ues_parcour( + parcour, with_sport=with_sport + ).filter(UniteEns.semestre_idx == self.semestre_id) + } ) - # si le sem. ne coche aucun parcours, prend toutes les UE + ues = sem_ues.values() + return sorted(ues, key=attrgetter("numero")) else: sem_ues = db.session.query(UniteEns).filter( ModuleImpl.formsemestre_id == self.id, Module.id == ModuleImpl.module_id, UniteEns.id == Module.ue_id, ) - if not with_sport: - sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT) - return sem_ues.order_by(UniteEns.numero) - - def query_ues_parcours_etud(self, etudid: int) -> Query: - """XXX inutilisé à part pour un test unitaire => supprimer ? - UEs que suit l'étudiant dans ce semestre BUT - en fonction du parcours dans lequel il est inscrit. - Si l'étudiant n'est inscrit à aucun parcours, - renvoie uniquement les UEs de tronc commun (sans parcours). - - Si voulez les UE d'un parcours, il est plus efficace de passer par - `formation.query_ues_parcour(parcour)`. - """ - return self.query_ues().filter( - FormSemestreInscription.etudid == etudid, - FormSemestreInscription.formsemestre == self, - UniteEns.niveau_competence_id == ApcNiveau.id, - ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id, - ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, - or_( - ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id, - and_( - FormSemestreInscription.parcour_id.is_(None), - UniteEns.parcour_id.is_(None), - ), - ), - ) + if not with_sport: + sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT) + return sem_ues.order_by(UniteEns.numero).all() @cached_property def modimpls_sorted(self) -> list[ModuleImpl]: @@ -961,7 +945,7 @@ class FormationModalite(db.Model): ) # code titre = db.Column(db.Text()) # texte explicatif # numero = ordre de presentation) - numero = db.Column(db.Integer) + numero = db.Column(db.Integer, nullable=False, default=0) @staticmethod def insert_modalites(): diff --git a/app/models/groups.py b/app/models/groups.py index 1920b3e92..5c9ae1688 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -7,6 +7,7 @@ """ScoDoc models: Groups & partitions """ +from operator import attrgetter from app import db from app.models import SHORT_STR_LEN @@ -29,7 +30,7 @@ class Partition(db.Model): # "TD", "TP", ... (NULL for 'all') partition_name = db.Column(db.String(SHORT_STR_LEN)) # Numero = ordre de presentation) - numero = db.Column(db.Integer) + numero = db.Column(db.Integer, nullable=False, default=0) # Calculer le rang ? bul_show_rank = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" @@ -92,7 +93,7 @@ class Partition(db.Model): d.pop("formsemestre", None) if with_groups: - groups = sorted(self.groups, key=lambda g: (g.numero or 0, g.group_name)) + groups = sorted(self.groups, key=attrgetter("numero", "group_name")) # un dict et non plus une liste, pour JSON d["groups"] = { group.id: group.to_dict(with_partition=False) for group in groups @@ -121,7 +122,7 @@ class GroupDescr(db.Model): # "A", "C2", ... (NULL for 'all'): group_name = db.Column(db.String(GROUPNAME_STR_LEN)) # Numero = ordre de presentation - numero = db.Column(db.Integer) + numero = db.Column(db.Integer, nullable=False, default=0) etuds = db.relationship( "Identite", diff --git a/app/models/modules.py b/app/models/modules.py index 1e89db7f9..3c04f097f 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -33,7 +33,7 @@ class Module(db.Model): # pas un id mais le numéro du semestre: 1, 2, ... # note: en APC, le semestre qui fait autorité est celui de l'UE semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") - numero = db.Column(db.Integer) # ordre de présentation + numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation # id de l'element pedagogique Apogee correspondant: code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) # Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum) diff --git a/app/models/ues.py b/app/models/ues.py index 6babec617..ef2aecf2a 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -21,7 +21,7 @@ class UniteEns(db.Model): ue_id = db.synonym("id") formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) acronyme = db.Column(db.Text(), nullable=False) - numero = db.Column(db.Integer) # ordre de présentation + numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation titre = db.Column(db.Text()) # Le semestre_idx n'est pas un id mais le numéro du semestre: 1, 2, ... # En ScoDoc7 et pour les formations classiques, il est NULL @@ -56,11 +56,10 @@ class UniteEns(db.Model): ) niveau_competence = db.relationship("ApcNiveau", back_populates="ues") - # Une ue appartient soit à tous les parcours (tronc commun), soit à un seul: - parcour_id = db.Column( - db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True + # Une UE appartient soit à tous les parcours (tronc commun), soit à un sous-ensemble + parcours = db.relationship( + ApcParcours, secondary="ue_parcours", backref=db.backref("ues", lazy=True) ) - parcour = db.relationship("ApcParcours", back_populates="ues") # relations matieres = db.relationship("Matiere", lazy="dynamic", backref="ue") @@ -115,7 +114,9 @@ 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(with_annees=False) if self.parcour else None + e["parcours"] = [ + parcour.to_dict(with_annees=False) for parcour in self.parcours + ] if with_module_ue_coefs: if convert_objects: e["module_ue_coefs"] = [ @@ -184,27 +185,86 @@ class UniteEns(db.Model): return {x.strip() for x in self.code_apogee.split(",") if x} return set() - def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int): - "raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre" - # Les UE du même semestre que nous: - ues_sem = self.formation.ues.filter_by(semestre_idx=self.semestre_idx) - if (new_niveau_id, new_parcour_id) in ( - (oue.niveau_competence_id, oue.parcour_id) - for oue in ues_sem - if oue.id != self.id - ): - log( - f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé" - ) - raise ScoFormationConflict() + def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]: + """set des ids de niveaux dans les parcours listés""" + return set.union( + *[ + { + n.id + for n in self.niveau_competence.niveaux_annee_de_parcours( + parcour, self.annee(), self.formation.referentiel_competence + ) + } + for parcour in parcours + ] + ) - def set_niveau_competence(self, niveau: ApcNiveau): + def check_niveau_unique_dans_parcours( + self, niveau: ApcNiveau, parcours=list[ApcParcours] + ) -> tuple[bool, str]: + """Vérifie que + - le niveau est dans au moins l'un des parcours listés; + - et que l'un des parcours associé à cette UE ne contient pas + déjà une UE associée au niveau donné dans une autre année. + Renvoie: (True, "") si ok, sinon (False, message). + """ + # Le niveau est-il dans l'un des parcours listés ? + if parcours: + if niveau.id not in self._parcours_niveaux_ids(parcours): + log( + f"Le niveau {niveau} ne fait pas partie des parcours de l'UE {self}." + ) + return ( + False, + f"""Le niveau { + niveau.libelle} ne fait pas partie des parcours de l'UE {self.acronyme}.""", + ) + + for parcour in parcours or [None]: + if parcour is None: + code_parcour = "TC" + ues_meme_niveau = [ + ue + for ue in self.formation.query_ues_parcour(None).filter( + UniteEns.niveau_competence == niveau + ) + ] + else: + code_parcour = parcour.code + ues_meme_niveau = [ + ue + for ue in parcour.ues + if ue.formation_id == self.formation_id + and ue.niveau_competence_id == niveau.id + ] + if ues_meme_niveau: + if len(ues_meme_niveau) > 1: # deja 2 UE sur ce niveau + msg = f"""Niveau "{ + niveau.libelle}" déjà associé à deux UE du parcours {code_parcour}""" + log("check_niveau_unique_dans_parcours: " + msg) + return False, msg + # s'il y a déjà une UE associée à ce niveau, elle doit être dans l'autre semestre + # de la même année scolaire + other_semestre_idx = self.semestre_idx + ( + 2 * (self.semestre_idx % 2) - 1 + ) + if ues_meme_niveau[0].semestre_idx != other_semestre_idx: + msg = f"""Niveau "{ + niveau.libelle}" associé à une autre année du parcours {code_parcour}""" + log("check_niveau_unique_dans_parcours: " + msg) + return False, msg + + return True, "" + + def set_niveau_competence(self, niveau: ApcNiveau) -> tuple[bool, str]: """Associe cette UE au niveau de compétence indiqué. - Le niveau doit être dans le parcours de l'UE, s'il y en a un. + Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas + de tronc commun). Assure que ce soit la seule dans son parcours. Sinon, raises ScoFormationConflict. Si niveau est None, désassocie. + Returns True if (de)association done, False on error. """ if niveau.id == self.niveau_competence_id: return True # nothing to do @@ -215,41 +275,52 @@ class UniteEns(db.Model): if not ok: return ok, error_message self.niveau_competence = niveau - db.session.add(self) db.session.commit() # Invalidation du cache self.formation.invalidate_cached_sems() log(f"ue.set_niveau_competence( {self}, {niveau} )") + return True, "" - def set_parcour(self, parcour: ApcParcours): - """Associe cette UE au parcours indiqué. - Assure que ce soit la seule dans son parcours. - Sinon, raises ScoFormationConflict. - - Si niveau est None, désassocie. + def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]: + """Associe cette UE aux parcours indiqués. + Si un niveau est déjà associé, vérifie sa cohérence. + Renvoie (True, "") si ok, sinon (False, error_message) """ - if (parcour is not None) and self.niveau_competence is not None: - self._check_apc_conflict(self.niveau_competence.id, parcour.id) - self.parcour = parcour # Le niveau est-il dans ce parcours ? Sinon, l'enlève + # breakpoint() if ( - parcour + parcours and self.niveau_competence - and self.niveau_competence.id - not in ( - n.id - for n in self.niveau_competence.niveaux_annee_de_parcours( - parcour, self.annee(), self.formation.referentiel_competence - ) - ) + and self.niveau_competence.id not in self._parcours_niveaux_ids(parcours) ): self.niveau_competence = None + + if parcours and self.niveau_competence: + ok, error_message = self.check_niveau_unique_dans_parcours( + self.niveau_competence, parcours + ) + if not ok: + return False, error_message + + self.parcours = parcours db.session.add(self) db.session.commit() # Invalidation du cache self.formation.invalidate_cached_sems() - log(f"ue.set_parcour( {self}, {parcour} )") + log(f"ue.set_parcours( {self}, {parcours} )") + return True, "" + + +class UEParcours(db.Model): + """Association ue <-> parcours, indiquant les ECTS""" + + __tablename__ = "ue_parcours" + ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), primary_key=True) + parcours_id = db.Column( + db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True + ) + ects = db.Column(db.Float, nullable=True) # si NULL, on prendra les ECTS de l'UE class DispenseUE(db.Model): diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index a19dca83c..2cfb1cc54 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -1009,10 +1009,7 @@ class ApoData(object): ] ) codes_ues = set().union( - *[ - ue.get_codes_apogee() - for ue in formsemestre.query_ues(with_sport=True) - ] + *[ue.get_codes_apogee() for ue in formsemestre.get_ues(with_sport=True)] ) s = set() codes_by_sem[sem["formsemestre_id"]] = s diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index a947bc63e..c00802f39 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -107,7 +107,7 @@ def html_edit_formation_apc( icons=icons, ues_by_sem=ues_by_sem, ects_by_sem=ects_by_sem, - form_ue_choix_niveau=apc_edit_ue.form_ue_choix_niveau, + form_ue_choix_parcours_niveau=apc_edit_ue.form_ue_choix_parcours_niveau, ), ] for semestre_idx in semestre_ids: diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 166268e26..69ab1efe6 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -738,8 +738,10 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list ) H = [ html_sco_header.sco_header( - cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"], - javascripts=[ + cssstyles=html_sco_header.BOOTSTRAP_MULTISELECT_CSS + + ["libjs/jQuery-tagEditor/jquery.tag-editor.css"], + javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + + [ "libjs/jinplace-1.2.1.min.js", "js/ue_list.js", "js/edit_ue.js", diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index fd15fbf24..a4b4414c4 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -79,7 +79,7 @@ def evaluation_create_form( mod = modimpl_o["module"] formsemestre_id = modimpl_o["formsemestre_id"] formsemestre = modimpl.formsemestre - sem_ues = formsemestre.query_ues(with_sport=False).all() + sem_ues = formsemestre.get_ues(with_sport=False) is_malus = mod["module_type"] == ModuleType.MALUS is_apc = mod["module_type"] in (ModuleType.RESSOURCE, ModuleType.SAE) preferences = sco_preferences.SemPreferences(formsemestre.id) diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index e2d6fd962..fa169c680 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -128,8 +128,10 @@ def formation_export_dict( ue_dict["apc_niveau_annee"] = ue.niveau_competence.annee ue_dict["apc_niveau_ordre"] = ue.niveau_competence.ordre # Et le parcour: - if ue.parcour: - ue_dict["parcour"] = [ue.parcour.to_dict(with_annees=False)] + if ue.parcours: + ue_dict["parcours"] = [ + parcour.to_dict(with_annees=False) for parcour in ue.parcours + ] # pour les coefficients: ue_dict["reference"] = ue.id if ue_reference_style == "id" else ue.acronyme if not export_ids: @@ -372,6 +374,7 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False): # -- create matieres for mat_info in ue_info[2]: + # Backward compat: un seul parcours par UE (ScoDoc < 9.4.71) if mat_info[0] == "parcour": # Parcours (BUT) code_parcours = mat_info[1]["code"] @@ -380,11 +383,28 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False): referentiel_id=referentiel_competence_id, ).first() if parcour: - ue.parcour = parcour + ue.parcours = [parcour] db.session.add(ue) else: + flash(f"Attention: parcours {code_parcours} inexistant !") log(f"Warning: parcours {code_parcours} inexistant !") continue + elif mat_info[0] == "parcours": + # Parcours (BUT), liste (ScoDoc > 9.4.70) + codes_parcours = mat_info[1]["code"] + for code_parcours in codes_parcours: + parcour = ApcParcours.query.filter_by( + code=code_parcours, + referentiel_id=referentiel_competence_id, + ).first() + if parcour: + ue.parcours.append(parcour) + else: + flash(f"Attention: parcours {code_parcours} inexistant !") + log(f"Warning: parcours {code_parcours} inexistant !") + db.session.add(ue) + continue + assert mat_info[0] == "matiere" mat_info[1]["ue_id"] = ue_id mat_id = sco_edit_matiere.do_matiere_create(mat_info[1]) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 38bc79d65..818063ce9 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -37,6 +37,7 @@ from flask import flash, redirect, render_template, url_for from flask_login import current_user from app import log +from app.but.cursus_but import formsemestre_warning_apc_setup from app.comp import res_sem from app.comp.res_common import ResultatsSemestre from app.comp.res_compat import NotesTableCompat @@ -604,7 +605,7 @@ def formsemestre_description_table( columns_ids += ["Coef."] ues = [] # liste des UE, seulement en APC pour les coefs else: - ues = formsemestre.query_ues().all() + ues = formsemestre.get_ues() columns_ids += [f"ue_{ue.id}" for ue in ues] if sco_preferences.get_preference("bul_show_ects", formsemestre_id): columns_ids += ["ects"] @@ -1057,6 +1058,7 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True): formsemestre_status_head( formsemestre_id=formsemestre_id, page_title="Tableau de bord" ), + formsemestre_warning_apc_setup(formsemestre, nt), formsemestre_warning_etuds_sans_note(formsemestre, nt) if can_change_all_notes else "", @@ -1282,7 +1284,7 @@ def formsemestre_tableau_modules( """ ) if mod.module_type in (ModuleType.RESSOURCE, ModuleType.SAE): - coefs = mod.ue_coefs_list(ues=formsemestre.query_ues().all()) + coefs = mod.ue_coefs_list(ues=formsemestre.get_ues()) H.append(f'') for coef in coefs: if coef[1] > 0: diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 6f0f3e7a5..b2686445a 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -606,7 +606,9 @@ def formsemestre_recap_parcours_table( else: # si l'étudiant n'est pas inscrit à un parcours mais que le semestre a plus d'UE # signale un éventuel problème: - if nt.formsemestre.query_ues().count() > len(nt.etud_ues_ids(etudid)): + if len(nt.formsemestre.get_ues()) > len( + nt.etud_ues_ids(etudid) + ): # XXX sans dispenses parcours_name = f""" {scu.EMO_WARNING} pas de parcours """ diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 2742519e7..06cac36d0 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -982,8 +982,8 @@ def icontag(name, file_format="png", no_size=False, **attrs): file_format, ), ) - im = PILImage.open(img_file) - width, height = im.size[0], im.size[1] + with PILImage.open(img_file) as image: + width, height = image.size[0], image.size[1] ICONSIZES[name] = (width, height) # cache else: width, height = ICONSIZES[name] diff --git a/app/static/js/edit_ue.js b/app/static/js/edit_ue.js index ebaaf139d..4b64c9a4e 100644 --- a/app/static/js/edit_ue.js +++ b/app/static/js/edit_ue.js @@ -89,7 +89,7 @@ function update_menus_niveau_competence() { // ); // nouveau: - document.querySelectorAll("select.select_niveau_ue").forEach( + document.querySelectorAll("select.niveau_select").forEach( elem => { let ue_id = elem.dataset.ue_id; $.get("get_ue_niveaux_options_html", @@ -102,4 +102,66 @@ function update_menus_niveau_competence() { ); } ); -} \ No newline at end of file +} + +// ---- Nouveau formulaire choix parcours et niveau ----- +//document.querySelectorAll("select.select_ue_parcours").forEach( +// elem => { elem.addEventListener('change', change_ue_parcours); } +//); +$().ready(function () { + $('select.select_ue_parcours').multiselect( + { + includeSelectAllOption: false, + nonSelectedText: 'choisir...', + // buttonContainer: '
', + onChange: function (element, checked) { + var parent = element.parent(); + var selectedOptions = parent.getValue().split(","); + let set_ue_parcours = element.context.dataset.set_ue_parcours; + + fetch(set_ue_parcours, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(selectedOptions) + }) + .then(response => response.json()) + .then(data => { + if (!data.status) { + sco_message(data.message); + // get the option element corresponding to the selected value + var option = parent.find('option[value="' + element.val() + '"]'); + // uncheck the option + option.prop('selected', false); + // refresh the multiselect to reflect the change + parent.multiselect('refresh'); + } + }) + .catch(error => console.error('Error: ' + error)); + + // // referme le menu apres chaque choix: + // $("#group_selector .btn-group").removeClass('open'); + + // if ($("#group_ids_sel").hasClass("submit_on_change")) { + // submit_group_selector(); + // } + } + } + ); +}); + +function change_ue_parcours(event) { + const multiselect = event.target; + const selectedOptions = Array.from(this.selectedOptions).map(option => option.value); + fetch('/set_option/', { // XXX TODO + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(selectedOptions) + }) + .then(response => response.json()) + .then(data => console.log('Success!')) + .catch(error => console.error('Error: ' + error)); +}; diff --git a/app/tables/recap.py b/app/tables/recap.py index ded33d0ea..73764ed22 100644 --- a/app/tables/recap.py +++ b/app/tables/recap.py @@ -74,7 +74,7 @@ class TableRecap(tb.Table): # couples (modimpl, ue) effectivement présents dans la table: self.modimpl_ue_ids = set() - ues = res.formsemestre.query_ues(with_sport=True) # avec bonus + ues = res.formsemestre.get_ues(with_sport=True) # avec bonus ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] if res.formsemestre.etuds_inscriptions: # table non vide diff --git a/app/templates/pn/form_ues.j2 b/app/templates/pn/form_ues.j2 index 1c2d74bf7..6ac091b04 100644 --- a/app/templates/pn/form_ues.j2 +++ b/app/templates/pn/form_ues.j2 @@ -65,7 +65,7 @@ }}">modifier {% endif %} - {{ form_ue_choix_niveau(ue)|safe }} + {{ form_ue_choix_parcours_niveau(ue)|safe }} {% if ue.type == 1 and ue.modules.count() == 0 %} diff --git a/app/templates/pn/ue_choix_parcours_niveau.j2 b/app/templates/pn/ue_choix_parcours_niveau.j2 new file mode 100644 index 000000000..b19bd674b --- /dev/null +++ b/app/templates/pn/ue_choix_parcours_niveau.j2 @@ -0,0 +1,13 @@ +{# inclu par form_ues.j2 #} + +
+ {{ form_ue_parcours_niveau.csrf_token }} +
+ {{ form_ue_parcours_niveau.niveau_select.label }} + {{ form_ue_parcours_niveau.niveau_select }} + + {{ form_ue_parcours_niveau.parcours_multiselect.label }} + {{ form_ue_parcours_niveau.parcours_multiselect }} +
+
+ diff --git a/app/views/notes.py b/app/views/notes.py index 046e078f1..09f3dee64 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -421,25 +421,6 @@ def set_ue_niveau_competence(): return "", 204 -@bp.route("/set_ue_parcours", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoChangeFormation) -def set_ue_parcours(): - """Associe UE et parcours BUT. - Si le parcour_id est "", désassocie.""" - ue_id = request.form.get("ue_id") - parcour_id = request.form.get("parcour_id") - if parcour_id == "": - parcour_id = None - ue: UniteEns = UniteEns.query.get_or_404(ue_id) - parcour = None if parcour_id is None else ApcParcours.query.get_or_404(parcour_id) - try: - ue.set_parcour(parcour) - except ScoFormationConflict: - return "", 409 # conflict - return "", 204 - - @bp.route("/get_ue_niveaux_options_html") @scodoc @permission_required(Permission.ScoView) @@ -448,6 +429,9 @@ def get_ue_niveaux_options_html(): niveau de compétences associé à une UE """ ue_id = request.args.get("ue_id") + if ue_id is None: + log("WARNING: get_ue_niveaux_options_html missing ue_id arg") + return "???" ue: UniteEns = UniteEns.query.get_or_404(ue_id) return apc_edit_ue.get_ue_niveaux_options_html(ue) diff --git a/app/views/pn_modules.py b/app/views/pn_modules.py index a5345dcae..57932450a 100644 --- a/app/views/pn_modules.py +++ b/app/views/pn_modules.py @@ -79,7 +79,7 @@ def table_modules_ue_coefs(formation_id, semestre_idx=None, parcours_id: int = N ues = [ ue for ue in ues - if (parcours_id == ue.parcour_id) or (ue.parcour_id is None) + if (parcours_id in (p.id for p in ue.parcours)) or (not ue.parcours) ] modules = [ mod @@ -113,13 +113,14 @@ def table_modules_ue_coefs(formation_id, semestre_idx=None, parcours_id: int = N cells = [] for (row, mod) in enumerate(modules, start=2): style = "champs champs_" + scu.ModuleType(mod.module_type).name + mod_parcours_ids = {p.id for p in mod.parcours} for (col, ue) in enumerate(ues, start=2): # met en gris les coefs qui devraient être nuls # car le module n'est pas dans le parcours de l'UE: if ( (mod.parcours is not None) - and (ue.parcour_id is not None) - and ue.parcour_id not in (p.id for p in mod.parcours) + and (ue.parcours) + and not {p.id for p in ue.parcours}.intersection(mod_parcours_ids) ): cell_style = style + " champs_coef_hors_parcours" else: diff --git a/migrations/versions/054dd6133b9c_association_ues_parcours.py b/migrations/versions/054dd6133b9c_association_ues_parcours.py new file mode 100644 index 000000000..ece852079 --- /dev/null +++ b/migrations/versions/054dd6133b9c_association_ues_parcours.py @@ -0,0 +1,98 @@ +"""Association UEs <-> parcours + +Revision ID: 054dd6133b9c +Revises: 6520faf67508 +Create Date: 2023-03-30 19:40:50.575293 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker # added by ev + + +# revision identifiers, used by Alembic. +revision = "054dd6133b9c" +down_revision = "6520faf67508" +branch_labels = None +depends_on = None + +Session = sessionmaker() + + +def upgrade(): + """Passe d'une relation UE - Parcours one-to-many à une relation many-to-many + crée la table d'association, copie l'éventuelle relation existante + puis supprime la clé étrangère parcour_id + """ + op.create_table( + "ue_parcours", + sa.Column("ue_id", sa.Integer(), nullable=False), + sa.Column("parcours_id", sa.Integer(), nullable=False), + sa.Column("ects", sa.Float(), nullable=True), + sa.ForeignKeyConstraint( + ["parcours_id"], + ["apc_parcours.id"], + ), + sa.ForeignKeyConstraint( + ["ue_id"], + ["notes_ue.id"], + ), + sa.PrimaryKeyConstraint("ue_id", "parcours_id"), + ) + # + bind = op.get_bind() + session = Session(bind=bind) + session.execute( + sa.text( + """ + INSERT INTO ue_parcours + SELECT id as ue_id, parcour_id as parcours_id + FROM notes_ue + WHERE parcour_id is not NULL; + """ + ) + ) + session.commit() + op.drop_column("notes_ue", "parcour_id") + + # Numeros non nullables + for table in ( + "apc_competence", + "apc_parcours", + "notes_form_modalites", + "notes_ue", + "notes_matieres", + "notes_modules", + "notes_evaluation", + "partition", + "group_descr", + ): + session.execute( + sa.text( + f"""UPDATE {table} SET numero=0 WHERE numero is NULL; + """ + ) + ) + session.commit() + op.alter_column(table, "numero", existing_type=sa.INTEGER(), nullable=False) + + +def downgrade(): + # + op.add_column( + "notes_ue", + sa.Column("parcour_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.drop_table("ue_parcours") + for table in ( + "apc_competence", + "apc_parcours", + "notes_form_modalites", + "notes_ue", + "notes_matieres", + "notes_modules", + "notes_evaluation", + "partition", + "group_descr", + ): + op.alter_column(table, "numero", existing_type=sa.INTEGER(), nullable=True) diff --git a/tests/ressources/yaml/cursus_but_gccd_cy.yaml b/tests/ressources/yaml/cursus_but_gccd_cy.yaml index 109c9969d..56446387a 100644 --- a/tests/ressources/yaml/cursus_but_gccd_cy.yaml +++ b/tests/ressources/yaml/cursus_but_gccd_cy.yaml @@ -87,15 +87,15 @@ Formation: competence: "Solutions TP" 'UE5.3': annee: BUT3 - parcours: RAPEB # + BEC + parcours: [RAPEB, BEC] competence: "Dimensionner" 'UE5.4': annee: BUT3 - parcours: BAT # + TP + parcours: [BAT, TP] competence: Organiser 'UE5.5': annee: BUT3 - parcours: BAT # + TP + parcours: [BAT, TP] competence: Piloter # S6 Parcours BAT + TP 'UE6.1': # Parcours BAT seulement @@ -104,19 +104,19 @@ Formation: competence: "Solutions Bâtiment" 'UE6.2': # Parcours TP seulement annee: BUT3 - parcours: TP # + BEC + parcours: [TP,BEC] competence: "Solutions TP" 'UE6.3': annee: BUT3 - parcours: RAPEB # + BEC + parcours: [RAPEB,BEC] competence: "Dimensionner" 'UE6.4': annee: BUT3 - parcours: BAT # + TP + parcours: [BAT, TP] competence: Organiser 'UE6.5': annee: BUT3 - parcours: BAT # + TP + parcours: [BAT,TP] competence: Piloter modules_parcours: diff --git a/tests/unit/setup.py b/tests/unit/setup.py index 110d81cb5..968745d48 100644 --- a/tests/unit/setup.py +++ b/tests/unit/setup.py @@ -111,7 +111,7 @@ def build_modules_with_evaluations( modimpl = models.ModuleImpl.query.get(moduleimpl_id) assert modimpl.formsemestre.formation.get_cursus().APC_SAE # BUT # Check ModuleImpl - ues = modimpl.formsemestre.query_ues().all() + ues = modimpl.formsemestre.get_ues() assert len(ues) == 3 # for _ in range(nb_evals_per_modimpl): diff --git a/tests/unit/test_but_jury.py b/tests/unit/test_but_jury.py index 218638e6c..db0b9aa27 100644 --- a/tests/unit/test_but_jury.py +++ b/tests/unit/test_but_jury.py @@ -24,7 +24,7 @@ from tests.unit import yaml_setup, yaml_setup_but import app from app.but.jury_but_validation_auto import formsemestre_validation_auto_but -from app.models import Formation, FormSemestre +from app.models import Formation, FormSemestre, UniteEns from config import TestConfig DEPT = TestConfig.DEPT_TEST @@ -133,7 +133,11 @@ def test_but_jury_GCCD_CY(test_client): assert parcour_BAT # check le nombre d'UE dans chaque semestre BUT: assert [ - len(formation.query_ues_parcour(parcour_BAT).filter_by(semestre_idx=i).all()) + len( + formation.query_ues_parcour(parcour_BAT) + .filter(UniteEns.semestre_idx == i) + .all() + ) for i in range(1, 7) ] == [5, 5, 5, 5, 3, 3] # Vérifie les UEs du parcours TP @@ -141,6 +145,10 @@ def test_but_jury_GCCD_CY(test_client): assert parcour_TP # check le nombre d'UE dans chaque semestre BUT: assert [ - len(formation.query_ues_parcour(parcour_TP).filter_by(semestre_idx=i).all()) + len( + formation.query_ues_parcour(parcour_TP) + .filter(UniteEns.semestre_idx == i) + .all() + ) for i in range(1, 7) ] == [5, 5, 5, 5, 3, 3] diff --git a/tests/unit/test_refcomp.py b/tests/unit/test_refcomp.py index 8121cf98d..d26f2a162 100644 --- a/tests/unit/test_refcomp.py +++ b/tests/unit/test_refcomp.py @@ -30,6 +30,9 @@ REF_MLT_XML = open( REF_GCCD_XML = open( "ressources/referentiels/but2022/competences/but-GCCD-05012022-081630.xml" ).read() +REF_INFO_XML = open( + "ressources/referentiels/but2022/competences/but-INFO-05012022-081701.xml" +).read() def test_but_refcomp(test_client): @@ -125,20 +128,20 @@ def test_refcomp_niveaux_mlt(test_client): # Vérifier les niveaux_by_parcours parcour = ref_comp.parcours.first() # BUT 1 - parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(1, parcour) + parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(1, [parcour]) assert parcours == [parcour] # le parcours indiqué assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert niveaux_by_parcours[parcour.id] == [] # tout en tronc commun en BUT1 MLT assert niveaux_by_parcours["TC"][0].competence.titre == "Transporter" assert len(niveaux_by_parcours["TC"]) == 3 # BUT 2 - parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(2, parcour) + parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(2, [parcour]) assert parcours == [parcour] # le parcours indiqué assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert len(niveaux_by_parcours[parcour.id]) == 1 assert len(niveaux_by_parcours["TC"]) == 3 # BUT 3 - parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(3, parcour) + parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(3, [parcour]) assert parcours == [parcour] # le parcours indiqué assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert len(niveaux_by_parcours[parcour.id]) == 1 @@ -182,13 +185,13 @@ def test_refcomp_niveaux_gccd(test_client): # Vérifier les niveaux_by_parcours parcour = ref_comp.parcours.first() # BUT 1 - parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(1, parcour) + parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(1, [parcour]) assert parcours == [parcour] # le parcours indiqué assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert len(niveaux_by_parcours[parcour.id]) == 0 assert len(niveaux_by_parcours["TC"]) == 5 # BUT 3 - parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(3, parcour) + parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(3, [parcour]) assert parcours == [parcour] # le parcours indiqué assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert len(niveaux_by_parcours[parcour.id]) == 3 diff --git a/tests/unit/yaml_setup_but.py b/tests/unit/yaml_setup_but.py index 864a2667e..b1f9c61a4 100644 --- a/tests/unit/yaml_setup_but.py +++ b/tests/unit/yaml_setup_but.py @@ -81,11 +81,23 @@ def associe_ues_et_parcours(formation: Formation, formation_infos: dict): assert ue is not None # l'UE doit exister dans la formation avec cet acronyme # Parcours: if ue_infos.get("parcours", False): - parcour = referentiel_competence.parcours.filter_by( - code=ue_infos["parcours"] - ).first() - assert parcour is not None # le parcours indiqué pour cette UE doit exister - ue.set_parcour(parcour) + # On peut spécifier un seul parcours (cas le plus fréquent) ou une liste + if isinstance(ue_infos["parcours"], list): + parcours = [ + referentiel_competence.parcours.filter_by(code=code_parcour).first() + for code_parcour in ue_infos["parcours"] + ] + assert ( + None not in parcours + ) # les parcours indiqués pour cette UE doivent exister + else: + parcours = referentiel_competence.parcours.filter_by( + code=ue_infos["parcours"] + ).all() + assert ( + len(parcours) == 1 + ) # le parcours indiqué pour cette UE doit exister + ue.set_parcours(parcours) # Niveaux compétences: competence = referentiel_competence.competences.filter_by( @@ -258,12 +270,19 @@ def check_deca_fields(formsemestre: FormSemestre, etud: Identite = None): assert deca.validation is None # pas encore de validation enregistrée assert False is deca.recorded assert deca.code_valide is None + parcour = deca.parcour + formation: Formation = formsemestre.formation + ues = ( + formation.query_ues_parcour(parcour) + .filter(UniteEns.semestre_idx == formsemestre.semestre_id) + .all() + ) if formsemestre.semestre_id % 2: assert deca.formsemestre_impair == formsemestre - assert formsemestre.query_ues_parcours_etud(etud.id).all() == deca.ues_impair + assert ues == deca.ues_impair else: assert deca.formsemestre_pair == formsemestre - assert formsemestre.query_ues_parcours_etud(etud.id).all() == deca.ues_pair + assert ues == deca.ues_pair assert deca.inscription_etat == scu.INSCRIT assert deca.inscription_etat_impair == scu.INSCRIT assert (deca.parcour is None) or ( @@ -271,24 +290,27 @@ def check_deca_fields(formsemestre: FormSemestre, etud: Identite = None): ) nb_ues = ( - len(deca.formsemestre_pair.query_ues_parcours_etud(etud.id).all()) + len( + formation.query_ues_parcour(parcour) + .filter(UniteEns.semestre_idx == deca.formsemestre_pair.semestre_id) + .all() + ) if deca.formsemestre_pair else 0 ) nb_ues += ( - len(deca.formsemestre_impair.query_ues_parcours_etud(etud.id).all()) + len( + formation.query_ues_parcour(parcour) + .filter(UniteEns.semestre_idx == deca.formsemestre_impair.semestre_id) + .all() + ) if deca.formsemestre_impair else 0 ) assert len(deca.decisions_ues) == nb_ues - nb_ues_un_sem = ( - len(deca.formsemestre_impair.query_ues_parcours_etud(etud.id).all()) - if deca.formsemestre_impair - else len(deca.formsemestre_pair.query_ues_parcours_etud(etud.id).all()) - ) - assert len(deca.niveaux_competences) == nb_ues_un_sem - assert deca.nb_competences == nb_ues_un_sem + assert len(deca.niveaux_competences) == len(ues) + assert deca.nb_competences == len(ues) def but_test_jury(formsemestre: FormSemestre, doc: dict): diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py index f62f4f728..0c634b066 100644 --- a/tools/fakedatabase/create_test_api_database.py +++ b/tools/fakedatabase/create_test_api_database.py @@ -262,7 +262,7 @@ def saisie_notes_evaluations(formsemestre: FormSemestre, user: User): date_debut = formsemestre.date_debut date_fin = formsemestre.date_fin - list_ues = formsemestre.query_ues() + list_ues = formsemestre.get_ues() def saisir_notes(evaluation_id: int, condition: int): """