diff --git a/app/api/departements.py b/app/api/departements.py index 7d056e468..b82966e35 100644 --- a/app/api/departements.py +++ b/app/api/departements.py @@ -187,7 +187,7 @@ def dept_etudiants(acronym: str): ] """ dept = Departement.query.filter_by(acronym=acronym).first_or_404() - return [etud.to_dict_short() for etud in dept.etudiants] + return [etud.to_dict_short() for etud in dept.etats_civils] @bp.route("/departement/id//etudiants") @@ -200,7 +200,7 @@ def dept_etudiants_by_id(dept_id: int): Retourne la liste des étudiants d'un département d'id donné. """ dept = Departement.query.get_or_404(dept_id) - return [etud.to_dict_short() for etud in dept.etudiants] + return [etud.to_dict_short() for etud in dept.etats_civils] @bp.route("/departement//formsemestres_ids") diff --git a/app/forms/pe/pe_sem_recap.py b/app/forms/pe/pe_sem_recap.py new file mode 100644 index 000000000..de2dbfd50 --- /dev/null +++ b/app/forms/pe/pe_sem_recap.py @@ -0,0 +1,65 @@ +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Formulaire options génération table poursuite études (PE) +""" + +from flask_wtf import FlaskForm +from wtforms import BooleanField, HiddenField, SubmitField + + +class ParametrageClasseurPE(FlaskForm): + "Formulaire paramétrage génération classeur PE" + # cohorte_restreinte = BooleanField( + # "Restreindre aux étudiants inscrits dans le semestre (sans interclassement de promotion) (à venir)" + # ) + moyennes_tags = BooleanField( + "Générer les moyennes sur les tags de modules personnalisés (cf. programme de formation)", + default=True, + render_kw={"checked": ""}, + ) + moyennes_ue_res_sae = BooleanField( + "Générer les moyennes des ressources et des SAEs", + default=True, + render_kw={"checked": ""}, + ) + moyennes_ues_rcues = BooleanField( + "Générer les moyennes par RCUEs (compétences) et leurs synthèses HTML étudiant par étudiant", + default=True, + render_kw={"checked": ""}, + ) + + min_max_moy = BooleanField("Afficher les colonnes min/max/moy") + + # synthese_individuelle_etud = BooleanField( + # "Générer (suppose les RCUES)" + # ) + publipostage = BooleanField( + "Nomme les moyennes pour publipostage", + # default=False, + # render_kw={"checked": ""}, + ) + submit = SubmitField("Générer les classeurs poursuites d'études") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/pe/moys/__init__.py b/app/pe/moys/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/pe/moys/pe_interclasstag.py b/app/pe/moys/pe_interclasstag.py new file mode 100644 index 000000000..382d88feb --- /dev/null +++ b/app/pe/moys/pe_interclasstag.py @@ -0,0 +1,342 @@ +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +""" +Created on Thu Sep 8 09:36:33 2016 + +@author: barasc +""" + +import pandas as pd +import numpy as np + +from app.models import Identite +from app.pe import pe_affichage +from app.pe.moys import pe_tabletags, pe_moy, pe_moytag, pe_sxtag +from app.pe.rcss import pe_rcs +import app.pe.pe_comp as pe_comp +from app.scodoc.sco_utils import ModuleType + + +class InterClassTag(pe_tabletags.TableTag): + """ + Interclasse l'ensemble des étudiants diplômés à une année + donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S'), qu'il soit + de type SemX ou RCSemX, + en reportant les moyennes obtenues sur à la version tagguée + du RCS (de type SxTag ou RCSTag). + Sont ensuite calculés les classements (uniquement) + sur les étudiants diplômes. + + Args: + nom_rcs: Le nom de l'aggrégat + type_interclassement: Le type d'interclassement (par UE ou par compétences) + etudiants_diplomes: L'identité des étudiants diplômés + rcss: Un dictionnaire {(nom_rcs, fid_final): RCS} donnant soit + les SemX soit les RCSemX recencés par le jury PE + rcstag: Un dictionnaire {(nom_rcs, fid_final): RCSTag} donnant + soit les SxTag (associés aux SemX) + soit les RCSTags (associés au RCSemX) calculés par le jury PE + suivis: Un dictionnaire associé à chaque étudiant son rcss + (de la forme ``{etudid: {nom_rcs: RCS_suivi}}``) + """ + + def __init__( + self, + nom_rcs: str, + type_interclassement: str, + etudiants_diplomes: dict[int, Identite], + rcss: dict[(str, int) : pe_rcs.RCS], + rcstags: dict[(str, int) : pe_tabletags.TableTag], + suivis: dict[int:dict], + ): + pe_tabletags.TableTag.__init__(self) + + self.nom_rcs: str = nom_rcs + """Le nom du RCS interclassé""" + + # Le type d'interclassement + self.type = type_interclassement + + pe_affichage.pe_print( + f"*** Interclassement par 🗂️{type_interclassement} pour le RCS ⏯️{nom_rcs}" + ) + + # Les informations sur les étudiants diplômés + self.etuds: list[Identite] = list(etudiants_diplomes.values()) + """Identités des étudiants diplômés""" + self.add_etuds(self.etuds) + + self.diplomes_ids = set(etudiants_diplomes.keys()) + """Etudids des étudiants diplômés""" + + # Les RCS de l'aggrégat (SemX ou RCSemX) + self.rcss: dict[(str, int), pe_rcs.RCS] = {} + """Ensemble des SemX ou des RCSemX associés à l'aggrégat""" + for (nom, fid), rcs in rcss.items(): + if nom == nom_rcs: + self.rcss[(nom, fid)] = rcss + + # Les données tagguées + self.rcstags: dict[(str, int), pe_tabletags.TableTag] = {} + """Ensemble des SxTag ou des RCSTags associés à l'aggrégat""" + for rcs_id in self.rcss: + self.rcstags[rcs_id] = rcstags[rcs_id] + + # Les RCS (SemX ou RCSemX) suivis par les étudiants du jury, + # en ne gardant que ceux associés aux diplomés + self.suivis: dict[int, pe_rcs.RCS] = {} + """Association entre chaque étudiant et le SxTag ou RCSTag à prendre + pour l'aggrégat""" + for etudid in self.diplomes_ids: + self.suivis[etudid] = suivis[etudid][nom_rcs] + + # Les données sur les tags + self.tags_sorted = self._do_taglist() + """Liste des tags (triés par ordre alphabétique)""" + aff = pe_affichage.repr_tags(self.tags_sorted) + pe_affichage.pe_print(f"--> Tags : {aff}") + + # Les données sur les UEs (si SxTag) ou compétences (si RCSTag) + self.champs_sorted = self._do_ues_ou_competences_list() + """Les champs (UEs ou compétences) de l'interclassement""" + if self.type == pe_moytag.CODE_MOY_UE: + pe_affichage.pe_print( + f"--> UEs : {pe_affichage.aff_UEs(self.champs_sorted)}" + ) + else: + pe_affichage.pe_print( + f"--> Compétences : {pe_affichage.aff_competences(self.champs_sorted)}" + ) + + # Etudids triés + self.etudids_sorted = sorted(list(self.diplomes_ids)) + + self.nom = self.get_repr() + """Représentation textuelle de l'interclassement""" + + # Synthétise les moyennes/classements par tag + self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {} + for tag in self.tags_sorted: + # Les moyennes tous modules confondus + notes_gen = self.compute_notes_matrice(tag) + + # Les coefficients de la moyenne générale + coeffs = self.compute_coeffs_matrice(tag) + aff = pe_affichage.repr_profil_coeffs(coeffs, with_index=True) + pe_affichage.pe_print(f"--> Moyenne 👜{tag} avec coeffs: {aff} ") + + self.moyennes_tags[tag] = pe_moytag.MoyennesTag( + tag, + self.type, + notes_gen, + coeffs, # limite les moyennes aux étudiants de la promo + ) + + def get_repr(self) -> str: + """Une représentation textuelle""" + return f"{self.nom_rcs} par {self.type}" + + def _do_taglist(self): + """Synthétise les tags à partir des TableTags (SXTag ou RCSTag) + + Returns: + Une liste de tags triés par ordre alphabétique + """ + tags = [] + for rcstag in self.rcstags.values(): + tags.extend(rcstag.tags_sorted) + return sorted(set(tags)) + + def compute_notes_matrice(self, tag) -> pd.DataFrame: + """Construit la matrice de notes (etudids x champs) en + reportant les moyennes obtenues par les étudiants + aux semestres de l'aggrégat pour le tag visé. + + Les champs peuvent être des acronymes d'UEs ou des compétences. + + Args: + tag: Le tag visé + Return: + Le dataFrame (etudids x champs) + reportant les moyennes des étudiants aux champs + """ + # etudids_sorted: Les etudids des étudiants (diplômés) triés + # champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice + + # Partant d'un dataframe vierge + df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted) + + for rcstag in self.rcstags.values(): + # Charge les moyennes au tag d'un RCStag + if tag in rcstag.moyennes_tags: + moytag = rcstag.moyennes_tags[tag] + + notes = moytag.matrice_notes_gen # dataframe etudids x ues + + # Etudiants/Champs communs entre le RCSTag et les données interclassées + ( + etudids_communs, + champs_communs, + ) = pe_comp.find_index_and_columns_communs(df, notes) + + # Injecte les notes par tag + df.loc[etudids_communs, champs_communs] = notes.loc[ + etudids_communs, champs_communs + ] + + return df + + def compute_coeffs_matrice(self, tag) -> pd.DataFrame: + """Idem que compute_notes_matrices mais pour les coeffs + + Args: + tag: Le tag visé + Return: + Le dataFrame (etudids x champs) + reportant les moyennes des étudiants aux champs + """ + # etudids_sorted: Les etudids des étudiants (diplômés) triés + # champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice + + # Partant d'un dataframe vierge + df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted) + + for rcstag in self.rcstags.values(): + if tag in rcstag.moyennes_tags: + # Charge les coeffs au tag d'un RCStag + coeffs: pd.DataFrame = rcstag.moyennes_tags[tag].matrice_coeffs_moy_gen + + # Etudiants/Champs communs entre le RCSTag et les données interclassées + ( + etudids_communs, + champs_communs, + ) = pe_comp.find_index_and_columns_communs(df, coeffs) + + # Injecte les coeffs par tag + df.loc[etudids_communs, champs_communs] = coeffs.loc[ + etudids_communs, champs_communs + ] + + return df + + def _do_ues_ou_competences_list(self) -> list[str]: + """Synthétise les champs (UEs ou compétences) sur lesquels + sont calculés les moyennes. + + Returns: + Un dictionnaire {'acronyme_ue' : 'compétences'} + """ + dict_champs = [] + for rcstag in self.rcstags.values(): + if isinstance(rcstag, pe_sxtag.SxTag): + champs = rcstag.acronymes_sorted + else: # pe_rcstag.RCSTag + champs = rcstag.competences_sorted + dict_champs.extend(champs) + return sorted(set(dict_champs)) + + def has_tags(self): + """Indique si l'interclassement a des tags (cas d'un + interclassement sur un S5 qui n'a pas eu lieu) + """ + return len(self.tags_sorted) > 0 + + def _un_rcstag_significatif(self, rcsstags: dict[(str, int):pe_tabletags]): + """Renvoie un rcstag significatif (ayant des tags et des notes aux tags) + parmi le dictionnaire de rcsstags""" + for rcstag_id, rcstag in rcsstags.items(): + moystags: pe_moytag.MoyennesTag = rcstag.moyennes_tags + for tag, moystag in moystags.items(): + tags_tries = moystag.get_all_significant_tags() + if tags_tries: + return moystag + return None + + def compute_df_synthese_moyennes_tag( + self, tag, aggregat=None, type_colonnes=False, options={"min_max_moy": True} + ) -> pd.DataFrame: + """Construit le dataframe retraçant pour les données des moyennes + pour affichage dans la synthèse du jury PE. (cf. to_df()) + + Args: + etudids_sorted: Les etudids des étudiants (diplômés) triés + champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice + Return: + Le dataFrame (etudids x champs) + reportant les moyennes des étudiants aux champs + """ + if aggregat: + assert ( + aggregat == self.nom_rcs + ), "L'interclassement ciblé ne correspond pas à l'aggrégat visé" + + etudids_sorted = sorted(list(self.diplomes_ids)) + + if not self.rcstags: + return None + + # Partant d'un dataframe vierge + initialisation = False + df = pd.DataFrame() + + # Pour chaque rcs (suivi) associe la liste des etudids l'ayant suivi + asso_rcs_etudids = {} + for etudid in etudids_sorted: + rcs = self.suivis[etudid] + if rcs: + if rcs.rcs_id not in asso_rcs_etudids: + asso_rcs_etudids[rcs.rcs_id] = [] + asso_rcs_etudids[rcs.rcs_id].append(etudid) + + for rcs_id, etudids in asso_rcs_etudids.items(): + # Charge ses moyennes au RCSTag suivi + rcstag = self.rcstags[rcs_id] # Le SxTag ou RCSTag + # Charge la moyenne + if tag in rcstag.moyennes_tags: + moytag: pd.DataFrame = rcstag.moyennes_tags[tag] + df_moytag = moytag.to_df( + aggregat=aggregat, cohorte="Groupe", options=options + ) + + # Modif les colonnes au regard du 1er df_moytag significatif lu + if not initialisation: + df = pd.DataFrame( + np.nan, index=etudids_sorted, columns=df_moytag.columns + ) + colonnes = list(df_moytag.columns) + for col in colonnes: + if col.endswith("rang"): + df[col] = df[col].astype(str) + initialisation = True + + # Injecte les notes des étudiants + df.loc[etudids, :] = df_moytag.loc[etudids, :] + + return df diff --git a/app/pe/moys/pe_moy.py b/app/pe/moys/pe_moy.py new file mode 100644 index 000000000..38883edfe --- /dev/null +++ b/app/pe/moys/pe_moy.py @@ -0,0 +1,128 @@ +import numpy as np +import pandas as pd + +from app.comp.moy_sem import comp_ranks_series +from app.pe import pe_affichage + + +class Moyenne: + COLONNES = [ + "note", + "classement", + "rang", + "min", + "max", + "moy", + "nb_etuds", + "nb_inscrits", + ] + """Colonnes du df""" + + @classmethod + def get_colonnes_synthese(cls, with_min_max_moy): + if with_min_max_moy: + return ["note", "rang", "min", "max", "moy"] + else: + return ["note", "rang"] + + def __init__(self, notes: pd.Series): + """Classe centralisant la synthèse des moyennes/classements d'une série + de notes : + + * des "notes" : la Serie pandas des notes (float), + * des "classements" : la Serie pandas des classements (float), + * des "min" : la note minimum, + * des "max" : la note maximum, + * des "moy" : la moyenne, + * des "nb_inscrits" : le nombre d'étudiants ayant une note, + """ + self.notes = notes + """Les notes""" + self.etudids = list(notes.index) # calcul à venir + """Les id des étudiants""" + self.inscrits_ids = notes[notes.notnull()].index.to_list() + """Les id des étudiants dont la note est non nulle""" + self.df: pd.DataFrame = self.comp_moy_et_stat(self.notes) + """Le dataframe retraçant les moyennes/classements/statistiques""" + self.synthese = self.to_dict() + """La synthèse (dictionnaire) des notes/classements/statistiques""" + + def comp_moy_et_stat(self, notes: pd.Series) -> dict: + """Calcule et structure les données nécessaires au PE pour une série + de notes (pouvant être une moyenne d'un tag à une UE ou une moyenne générale + d'un tag) dans un dictionnaire spécifique. + + Partant des notes, sont calculés les classements (en ne tenant compte + que des notes non nulles). + + Args: + notes: Une série de notes (avec des éventuels NaN) + + Returns: + Un dictionnaire stockant les notes, les classements, le min, + le max, la moyenne, le nb de notes (donc d'inscrits) + """ + df = pd.DataFrame( + np.nan, + index=self.etudids, + columns=Moyenne.COLONNES, + ) + + # Supprime d'éventuelles chaines de caractères dans les notes + notes = pd.to_numeric(notes, errors="coerce") + df["note"] = notes + + # Les nb d'étudiants & nb d'inscrits + df["nb_etuds"] = len(self.etudids) + df["nb_etuds"] = df["nb_etuds"].astype(int) + + # Les étudiants dont la note n'est pas nulle + inscrits_ids = notes[notes.notnull()].index.to_list() + df.loc[inscrits_ids, "nb_inscrits"] = len(inscrits_ids) + # df["nb_inscrits"] = df["nb_inscrits"].astype(int) + + # Le classement des inscrits + notes_non_nulles = notes[inscrits_ids] + (class_str, class_int) = comp_ranks_series(notes_non_nulles) + df.loc[inscrits_ids, "classement"] = class_int + # df["classement"] = df["classement"].astype(int) + + # Le rang (classement/nb_inscrit) + df["rang"] = df["rang"].astype(str) + df.loc[inscrits_ids, "rang"] = ( + df.loc[inscrits_ids, "classement"].astype(int).astype(str) + + "/" + + df.loc[inscrits_ids, "nb_inscrits"].astype(int).astype(str) + ) + + # Les stat (des inscrits) + df.loc[inscrits_ids, "min"] = notes.min() + df.loc[inscrits_ids, "max"] = notes.max() + df.loc[inscrits_ids, "moy"] = notes.mean() + + return df + + def get_df_synthese(self, with_min_max_moy=None): + """Renvoie le df de synthese limité aux colonnes de synthese""" + colonnes_synthese = Moyenne.get_colonnes_synthese( + with_min_max_moy=with_min_max_moy + ) + df = self.df[colonnes_synthese].copy() + df["rang"] = df["rang"].replace("nan", "") + return df + + def to_dict(self) -> dict: + """Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques générale (but)""" + synthese = { + "notes": self.df["note"], + "classements": self.df["classement"], + "min": self.df["min"].mean(), + "max": self.df["max"].mean(), + "moy": self.df["moy"].mean(), + "nb_inscrits": self.df["nb_inscrits"].mean(), + } + return synthese + + def is_significatif(self) -> bool: + """Indique si la moyenne est significative (c'est-à-dire à des notes)""" + return self.synthese["nb_inscrits"] > 0 diff --git a/app/pe/moys/pe_moytag.py b/app/pe/moys/pe_moytag.py new file mode 100644 index 000000000..1d8bcc00f --- /dev/null +++ b/app/pe/moys/pe_moytag.py @@ -0,0 +1,169 @@ +import numpy as np +import pandas as pd + +from app import comp +from app.comp.moy_sem import comp_ranks_series +from app.pe.moys import pe_moy +from app.scodoc.sco_utils import ModuleType + +CODE_MOY_UE = "UEs" +CODE_MOY_COMPETENCES = "Compétences" +CHAMP_GENERAL = "Général" # Nom du champ de la moyenne générale + + +class MoyennesTag: + def __init__( + self, + tag: str, + type_moyenne: str, + matrice_notes_gen: pd.DataFrame, # etudids x colonnes + matrice_coeffs: pd.DataFrame, # etudids x colonnes + ): + """Classe centralisant la synthèse des moyennes/classements d'une série + d'étudiants à un tag donné, en différenciant les notes + obtenues aux UE et au général (toutes UEs confondues) + + + Args: + tag: Un tag + matrice_notes_gen: Les moyennes (etudid x acronymes_ues ou etudid x compétences) + aux différentes UEs ou compétences + # notes_gen: Une série de notes (moyenne) sous forme d'un ``pd.Series`` (toutes UEs confondues) + """ + self.tag = tag + """Le tag associé aux moyennes""" + + self.type = type_moyenne + """Le type de moyennes (par UEs ou par compétences)""" + + # Les moyennes par UE/compétences (ressources/SAEs confondues) + self.matrice_notes_gen: pd.DataFrame = matrice_notes_gen + """Les notes par UEs ou Compétences (DataFrame)""" + + self.matrice_coeffs_moy_gen: pd.DataFrame = matrice_coeffs + """Les coeffs à appliquer pour le calcul des moyennes générales + (toutes UE ou compétences confondues). NaN si étudiant non inscrit""" + + self.moyennes_gen: dict[int, pd.DataFrame] = {} + """Dataframes retraçant les moyennes/classements/statistiques des étudiants aux UEs""" + + self.etudids = self.matrice_notes_gen.index + """Les étudids renseignés dans les moyennes""" + + self.champs = self.matrice_notes_gen.columns + """Les champs (acronymes d'UE ou compétences) renseignés dans les moyennes""" + for col in self.champs: # if ue.type != UE_SPORT: + # Les moyennes tous modules confondus + notes = matrice_notes_gen[col] + self.moyennes_gen[col] = pe_moy.Moyenne(notes) + + # Les moyennes générales (toutes UEs confondues) + self.notes_gen = pd.Series(np.nan, index=self.matrice_notes_gen.index) + if self.has_notes(): + self.notes_gen = self.compute_moy_gen( + self.matrice_notes_gen, self.matrice_coeffs_moy_gen + ) + self.moyenne_gen = pe_moy.Moyenne(self.notes_gen) + """Dataframe retraçant les moyennes/classements/statistiques général (toutes UESs confondues et modules confondus)""" + + def has_notes(self): + """Détermine si les moyennes (aux UEs ou aux compétences) + ont des notes + + Returns: + True si la moytag a des notes, False sinon + """ + notes = self.matrice_notes_gen + + nbre_nan = notes.isna().sum().sum() + nbre_notes_potentielles = len(notes.index) * len(notes.columns) + if nbre_nan == nbre_notes_potentielles: + return False + else: + return True + + def compute_moy_gen(self, moys: pd.DataFrame, coeffs: pd.DataFrame) -> pd.Series: + """Calcule la moyenne générale (toutes UE/compétences confondus) + pour le tag considéré, en pondérant les notes obtenues au UE + par les coeff (généralement les crédits ECTS). + + Args: + moys: Les moyennes etudids x acronymes_ues/compétences + coeff: Les coeff etudids x ueids/compétences + """ + + # Calcule la moyenne générale dans le semestre (pondérée par le ECTS) + try: + moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects( + moys, + coeffs.fillna(0.0), + # formation_id=self.formsemestre.formation_id, + skip_empty_ues=True, + ) + except TypeError as e: + raise TypeError( + "Pb dans le calcul de la moyenne toutes UEs/compétences confondues" + ) + + return moy_gen_tag + + def to_df( + self, aggregat=None, cohorte=None, options={"min_max_moy": True} + ) -> pd.DataFrame: + """Renvoie le df synthétisant l'ensemble des données + connues + Adapte les intitulés des colonnes aux données fournies + (nom d'aggrégat, type de cohorte). + """ + if "min_max_moy" not in options or options["min_max_moy"]: + with_min_max_moy = True + else: + with_min_max_moy = False + + etudids_sorted = sorted(self.etudids) + + df = pd.DataFrame(index=etudids_sorted) + + # Ajout des notes pour tous les champs + champs = list(self.champs) + for champ in champs: + df_champ = self.moyennes_gen[champ].get_df_synthese( + with_min_max_moy=with_min_max_moy + ) # le dataframe + # Renomme les colonnes + + cols = [ + get_colonne_df(aggregat, self.tag, champ, cohorte, critere) + for critere in pe_moy.Moyenne.get_colonnes_synthese( + with_min_max_moy=with_min_max_moy + ) + ] + df_champ.columns = cols + df = df.join(df_champ) + + # Ajoute la moy générale + df_moy_gen = self.moyenne_gen.get_df_synthese(with_min_max_moy=with_min_max_moy) + cols = [ + get_colonne_df(aggregat, self.tag, CHAMP_GENERAL, cohorte, critere) + for critere in pe_moy.Moyenne.get_colonnes_synthese( + with_min_max_moy=with_min_max_moy + ) + ] + df_moy_gen.columns = cols + df = df.join(df_moy_gen) + + return df + + +def get_colonne_df(aggregat, tag, champ, cohorte, critere): + """Renvoie le tuple (aggregat, tag, champ, cohorte, critere) + utilisé pour désigner les colonnes du df""" + liste_champs = [] + if aggregat != None: + liste_champs += [aggregat] + + liste_champs += [tag, champ] + if cohorte != None: + liste_champs += [cohorte] + liste_champs += [critere] + return "|".join(liste_champs) diff --git a/app/pe/moys/pe_rcstag.py b/app/pe/moys/pe_rcstag.py new file mode 100644 index 000000000..cf135174b --- /dev/null +++ b/app/pe/moys/pe_rcstag.py @@ -0,0 +1,466 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +""" +Created on Fri Sep 9 09:15:05 2016 + +@author: barasc +""" + +from app.models import FormSemestre +from app.pe import pe_affichage +import pandas as pd +import numpy as np +from app.pe.rcss import pe_rcs, pe_rcsemx +import app.pe.moys.pe_sxtag as pe_sxtag +import app.pe.pe_comp as pe_comp +from app.pe.moys import pe_tabletags, pe_moytag +from app.scodoc.sco_utils import ModuleType + + +class RCSemXTag(pe_tabletags.TableTag): + def __init__( + self, + rcsemx: pe_rcsemx.RCSemX, + sxstags: dict[(str, int) : pe_sxtag.SxTag], + semXs_suivis: dict[int, dict], + ): + """Calcule les moyennes par tag (orientées compétences) + d'un regroupement de SxTag + (RCRCF), pour extraire les classements par tag pour un + groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous + participé au même semestre terminal. + + Args: + rcsemx: Le RCSemX (identifié par un nom et l'id de son semestre terminal) + sxstags: Les données sur les SemX taggués + semXs_suivis: Les données indiquant quels SXTags sont à prendre en compte + pour chaque étudiant + """ + pe_tabletags.TableTag.__init__(self) + + self.rcs_id: tuple(str, int) = rcsemx.rcs_id + """Identifiant du RCSemXTag (identique au RCSemX sur lequel il s'appuie)""" + + self.rcsemx: pe_rcsemx.RCSemX = rcsemx + """Le regroupement RCSemX associé au RCSemXTag""" + + self.semXs_suivis = semXs_suivis + """Les semXs suivis par les étudiants""" + + self.nom = self.get_repr() + """Représentation textuelle du RSCtag""" + + # Les données du semestre final + self.formsemestre_final: FormSemestre = rcsemx.formsemestre_final + """Le semestre final""" + self.fid_final: int = rcsemx.formsemestre_final.formsemestre_id + """Le fid du semestre final""" + + # Affichage pour debug + pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}") + + # Les données aggrégés (RCRCF + SxTags) + self.semXs_aggreges: dict[(str, int) : pe_rcsemx.RCSemX] = rcsemx.semXs_aggreges + """Les SemX aggrégés""" + self.sxstags_aggreges = {} + """Les SxTag associés aux SemX aggrégés""" + try: + for rcf_id in self.semXs_aggreges: + self.sxstags_aggreges[rcf_id] = sxstags[rcf_id] + except: + raise ValueError("Semestres SxTag manquants") + self.sxtags_connus = sxstags # Tous les sxstags connus + + # Les étudiants (etuds, états civils & etudis) + sems_dans_aggregat = rcsemx.aggregat + sxtag_final = self.sxstags_aggreges[(sems_dans_aggregat[-1], self.rcs_id[1])] + self.etuds = sxtag_final.etuds + """Les étudiants (extraits du semestre final)""" + self.add_etuds(self.etuds) + self.etudids_sorted = sorted(self.etudids) + """Les étudids triés""" + + # Les compétences (extraites de tous les Sxtags) + self.acronymes_ues_to_competences = self._do_acronymes_to_competences() + """L'association acronyme d'UEs -> compétence (extraites des SxTag aggrégés)""" + + self.competences_sorted = sorted( + set(self.acronymes_ues_to_competences.values()) + ) + """Compétences (triées par nom, extraites des SxTag aggrégés)""" + aff = pe_affichage.repr_comp_et_ues(self.acronymes_ues_to_competences) + pe_affichage.pe_print(f"--> Compétences : {', '.join(self.competences_sorted)}") + + # Les tags + self.tags_sorted = self._do_taglist() + """Tags extraits de tous les SxTag aggrégés""" + aff_tag = ["👜" + tag for tag in self.tags_sorted] + pe_affichage.pe_print(f"--> Tags : {', '.join(aff_tag)}") + + # Les moyennes + self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {} + + """Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)""" + for tag in self.tags_sorted: + pe_affichage.pe_print(f"--> Moyennes du tag 👜{tag}") + + # Traitement des inscriptions aux semX(tags) + # ****************************************** + # Cube d'inscription (etudids_sorted x compétences_sorted x sxstags) + # indiquant quel sxtag est valide pour chaque étudiant + inscr_df, inscr_cube = self.compute_inscriptions_comps_cube(tag) + + # Traitement des notes + # ******************** + # Cube de notes (etudids_sorted x compétences_sorted x sxstags) + notes_df, notes_cube = self.compute_notes_comps_cube(tag) + # Calcule les moyennes sous forme d'un dataframe en les "aggrégant" + # compétence par compétence + moys_competences = self.compute_notes_competences(notes_cube, inscr_cube) + + # Traitement des coeffs pour la moyenne générale + # *********************************************** + # Df des coeffs sur tous les SxTags aggrégés + coeffs_df, coeffs_cube = self.compute_coeffs_comps_cube(tag) + + # Synthèse des coefficients à prendre en compte pour la moyenne générale + matrice_coeffs_moy_gen = self.compute_coeffs_competences( + coeffs_cube, inscr_cube, notes_cube + ) + + # Affichage des coeffs + aff = pe_affichage.repr_profil_coeffs( + matrice_coeffs_moy_gen, with_index=True + ) + pe_affichage.pe_print(f" > Moyenne calculée avec pour coeffs : {aff}") + + # Mémorise les moyennes et les coeff associés + self.moyennes_tags[tag] = pe_moytag.MoyennesTag( + tag, + pe_moytag.CODE_MOY_COMPETENCES, + moys_competences, + matrice_coeffs_moy_gen, + ) + + def __eq__(self, other): + """Egalité de 2 RCS taggués sur la base de leur identifiant""" + return self.rcs_id == other.sxtag_id + + def get_repr(self, verbose=True) -> str: + """Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle + est basée)""" + if verbose: + return f"{self.__class__.__name__} basé sur " + self.rcsemx.get_repr( + verbose=verbose + ) + else: + return f"{self.__class__.__name__} {self.rcs_id}" + + def compute_notes_comps_cube(self, tag): + """Pour un tag donné, construit le cube de notes (etudid x competences x SxTag) + nécessaire au calcul des moyennes, + en remplaçant les données d'UE (obtenus du SxTag) par les compétences + + Args: + tag: Le tag visé + """ + # etudids_sorted: list[int], + # competences_sorted: list[str], + # sxstags: dict[(str, int) : pe_sxtag.SxTag], + notes_dfs = {} + + for sxtag_id, sxtag in self.sxstags_aggreges.items(): + # Partant d'un dataframe vierge + notes_df = pd.DataFrame( + np.nan, index=self.etudids_sorted, columns=self.competences_sorted + ) + # Charge les notes du semestre tag (copie car changement de nom de colonnes à venir) + if tag in sxtag.moyennes_tags: # si le tag est présent dans le semestre + moys_tag = sxtag.moyennes_tags[tag] + + notes = moys_tag.matrice_notes_gen.copy() # dataframe etudids x ues + + # Traduction des acronymes d'UE en compétences + acronymes_ues_columns = notes.columns + acronymes_to_comps = [ + self.acronymes_ues_to_competences[acro] + for acro in acronymes_ues_columns + ] + notes.columns = acronymes_to_comps + + # Les étudiants et les compétences communes + ( + etudids_communs, + comp_communes, + ) = pe_comp.find_index_and_columns_communs(notes_df, notes) + + # Recopie des notes et des coeffs + notes_df.loc[etudids_communs, comp_communes] = notes.loc[ + etudids_communs, comp_communes + ] + + # Supprime tout ce qui n'est pas numérique + # for col in notes_df.columns: + # notes_df[col] = pd.to_numeric(notes_df[col], errors="coerce") + + # Stocke les dfs + notes_dfs[sxtag_id] = notes_df + + """Réunit les notes sous forme d'un cube etudids x competences x semestres""" + sxtag_x_etudids_x_comps = [ + notes_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges + ] + notes_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1) + + return notes_dfs, notes_etudids_x_comps_x_sxtag + + def compute_coeffs_comps_cube(self, tag): + """Pour un tag donné, construit + le cube de coeffs (etudid x competences x SxTag) (traduisant les inscriptions + des étudiants aux UEs en fonction de leur parcours) + qui s'applique aux différents SxTag + en remplaçant les données d'UE (obtenus du SxTag) par les compétences + + Args: + tag: Le tag visé + """ + # etudids_sorted: list[int], + # competences_sorted: list[str], + # sxstags: dict[(str, int) : pe_sxtag.SxTag], + + coeffs_dfs = {} + + for sxtag_id, sxtag in self.sxstags_aggreges.items(): + # Partant d'un dataframe vierge + coeffs_df = pd.DataFrame( + np.nan, index=self.etudids_sorted, columns=self.competences_sorted + ) + if tag in sxtag.moyennes_tags: + moys_tag = sxtag.moyennes_tags[tag] + + # Charge les notes et les coeffs du semestre tag + coeffs = moys_tag.matrice_coeffs_moy_gen.copy() # les coeffs + + # Traduction des acronymes d'UE en compétences + acronymes_ues_columns = coeffs.columns + acronymes_to_comps = [ + self.acronymes_ues_to_competences[acro] + for acro in acronymes_ues_columns + ] + coeffs.columns = acronymes_to_comps + + # Les étudiants et les compétences communes + etudids_communs, comp_communes = pe_comp.find_index_and_columns_communs( + coeffs_df, coeffs + ) + + # Recopie des notes et des coeffs + coeffs_df.loc[etudids_communs, comp_communes] = coeffs.loc[ + etudids_communs, comp_communes + ] + + # Stocke les dfs + coeffs_dfs[sxtag_id] = coeffs_df + + """Réunit les coeffs sous forme d'un cube etudids x competences x semestres""" + sxtag_x_etudids_x_comps = [ + coeffs_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges + ] + coeffs_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1) + + return coeffs_dfs, coeffs_etudids_x_comps_x_sxtag + + def compute_inscriptions_comps_cube( + self, + tag, + ): + """Pour un tag donné, construit + le cube etudid x competences x SxTag traduisant quels sxtags est à prendre + en compte pour chaque étudiant. + Contient des 0 et des 1 pour indiquer la prise en compte. + + Args: + tag: Le tag visé + """ + # etudids_sorted: list[int], + # competences_sorted: list[str], + # sxstags: dict[(str, int) : pe_sxtag.SxTag], + # Initialisation + inscriptions_dfs = {} + + for sxtag_id, sxtag in self.sxstags_aggreges.items(): + # Partant d'un dataframe vierge + inscription_df = pd.DataFrame( + 0, index=self.etudids_sorted, columns=self.competences_sorted + ) + + # Les étudiants dont les résultats au sxtag ont été calculés + etudids_sxtag = sxtag.etudids_sorted + + # Les étudiants communs + etudids_communs = sorted(set(self.etudids_sorted) & set(etudids_sxtag)) + + # Acte l'inscription + inscription_df.loc[etudids_communs, :] = 1 + + # Stocke les dfs + inscriptions_dfs[sxtag_id] = inscription_df + + """Réunit les inscriptions sous forme d'un cube etudids x competences x semestres""" + sxtag_x_etudids_x_comps = [ + inscriptions_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges + ] + inscriptions_etudids_x_comps_x_sxtag = np.stack( + sxtag_x_etudids_x_comps, axis=-1 + ) + + return inscriptions_dfs, inscriptions_etudids_x_comps_x_sxtag + + def _do_taglist(self) -> list[str]: + """Synthétise les tags à partir des Sxtags aggrégés. + + Returns: + Liste de tags triés par ordre alphabétique + """ + tags = [] + for frmsem_id in self.sxstags_aggreges: + tags.extend(self.sxstags_aggreges[frmsem_id].tags_sorted) + return sorted(set(tags)) + + def _do_acronymes_to_competences(self) -> dict[str:str]: + """Synthétise l'association complète {acronyme_ue: competences} + extraite de toutes les données/associations des SxTags + aggrégés. + + Returns: + Un dictionnaire {'acronyme_ue' : 'compétences'} + """ + dict_competences = {} + for sxtag_id, sxtag in self.sxstags_aggreges.items(): + dict_competences |= sxtag.acronymes_ues_to_competences + return dict_competences + + def compute_notes_competences(self, set_cube: np.array, inscriptions: np.array): + """Calcule la moyenne par compétences (à un tag donné) sur plusieurs semestres (partant du set_cube). + + La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles + + *Remarque* : Adaptation de moy_ue.compute_ue_moys_apc au cas des moyennes de tag + par aggrégat de plusieurs semestres. + + Args: + set_cube: notes moyennes aux compétences ndarray + (etuds x UEs|compétences x sxtags), des floats avec des NaN + inscriptions: inscrptions aux compétences ndarray + (etuds x UEs|compétences x sxtags), des 0 et des 1 + Returns: + Un DataFrame avec pour columns les moyennes par tags, + et pour rows les etudid + """ + # etudids_sorted: liste des étudiants (dim. 0 du cube) + # competences_sorted: list (dim. 1 du cube) + nb_etuds, nb_comps, nb_semestres = set_cube.shape + # assert nb_etuds == len(etudids_sorted) + # assert nb_comps == len(competences_sorted) + + # Applique le masque d'inscriptions + set_cube_significatif = set_cube * inscriptions + + # Quelles entrées du cube contiennent des notes ? + mask = ~np.isnan(set_cube_significatif) + + # Enlève les NaN du cube de notes pour les entrées manquantes + set_cube_no_nan = np.nan_to_num(set_cube_significatif, nan=0.0) + + # Les moyennes par tag + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etud_moy_tag = np.sum(set_cube_no_nan, axis=2) / np.sum(mask, axis=2) + + # Le dataFrame des notes moyennes + etud_moy_tag_df = pd.DataFrame( + etud_moy_tag, + index=self.etudids_sorted, # les etudids + columns=self.competences_sorted, # les competences + ) + etud_moy_tag_df.fillna(np.nan) + + return etud_moy_tag_df + + def compute_coeffs_competences( + self, + coeff_cube: np.array, + inscriptions: np.array, + set_cube: np.array, + ): + """Calcule les coeffs à utiliser pour la moyenne générale (toutes compétences + confondues), en fonction des inscriptions. + + Args: + coeffs_cube: coeffs impliqués dans la moyenne générale (semestres par semestres) + inscriptions: inscriptions aux UES|Compétences ndarray + (etuds x UEs|compétences x sxtags), des 0 ou des 1 + set_cube: les notes + + + Returns: + Un DataFrame de coefficients (etudids_sorted x compétences_sorted) + """ + # etudids_sorted: liste des étudiants (dim. 0 du cube) + # competences_sorted: list (dim. 1 du cube) + nb_etuds, nb_comps, nb_semestres = inscriptions.shape + # assert nb_etuds == len(etudids_sorted) + # assert nb_comps == len(competences_sorted) + + # Applique le masque des inscriptions aux coeffs et aux notes + coeffs_significatifs = coeff_cube * inscriptions + + # Enlève les NaN du cube de notes pour les entrées manquantes + coeffs_cube_no_nan = np.nan_to_num(coeffs_significatifs, nan=0.0) + + # Quelles entrées du cube contiennent des notes ? + mask = ~np.isnan(set_cube) + + # Retire les coefficients associés à des données sans notes + coeffs_cube_no_nan = coeffs_cube_no_nan * mask + + # Somme les coefficients (correspondant à des notes) + coeff_tag = np.sum(coeffs_cube_no_nan, axis=2) + + # Le dataFrame des coeffs + coeffs_df = pd.DataFrame( + coeff_tag, index=self.etudids_sorted, columns=self.competences_sorted + ) + # Remet à Nan les coeffs à 0 + coeffs_df = coeffs_df.fillna(np.nan) + + return coeffs_df diff --git a/app/pe/moys/pe_ressemtag.py b/app/pe/moys/pe_ressemtag.py new file mode 100644 index 000000000..c0d473171 --- /dev/null +++ b/app/pe/moys/pe_ressemtag.py @@ -0,0 +1,463 @@ +# -*- pole: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Generfal Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +""" +Created on Fri Sep 9 09:15:05 2016 + +@author: barasc +""" +import pandas as pd + +from app import ScoValueError +from app import comp +from app.comp.res_but import ResultatsSemestreBUT +from app.models import FormSemestre, UniteEns +import app.pe.pe_affichage as pe_affichage +import app.pe.pe_etudiant as pe_etudiant +from app.pe.moys import pe_tabletags, pe_moytag +from app.scodoc import sco_tag_module +from app.scodoc import codes_cursus as sco_codes +from app.scodoc.sco_utils import * + + +class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag): + """ + Un ResSemBUTTag représente les résultats des étudiants à un semestre, en donnant + accès aux moyennes par tag. + Il s'appuie principalement sur un ResultatsSemestreBUT. + """ + + def __init__( + self, + formsemestre: FormSemestre, + options={"moyennes_tags": True, "moyennes_ue_res_sae": False}, + ): + """ + Args: + formsemestre: le ``FormSemestre`` sur lequel il se base + options: Un dictionnaire d'options + """ + ResultatsSemestreBUT.__init__(self, formsemestre) + pe_tabletags.TableTag.__init__(self) + + # Le nom du res_semestre taggué + self.nom = self.get_repr(verbose=True) + + # Les étudiants (etuds, états civils & etudis) ajouté + self.add_etuds(self.etuds) + self.etudids_sorted = sorted(self.etudids) + """Les etudids des étudiants du ResultatsSemestreBUT triés""" + + pe_affichage.pe_print( + f"*** ResSemBUTTag du {self.nom} => {len(self.etudids_sorted)} étudiants" + ) + + # Les UEs (et les dispenses d'UE) + self.ues_standards: list[UniteEns] = [ + ue for ue in self.ues if ue.type == sco_codes.UE_STANDARD + ] + """Liste des UEs standards du ResultatsSemestreBUT""" + + # Les parcours des étudiants à ce semestre + self.parcours = [] + """Parcours auxquels sont inscrits les étudiants""" + for etudid in self.etudids_sorted: + parcour = self.formsemestre.etuds_inscriptions[etudid].parcour + if parcour: + self.parcours += [parcour.libelle] + else: + self.parcours += [None] + + # Les UEs en fonction des parcours + self.ues_inscr_parcours_df = self.load_ues_inscr_parcours() + """Inscription des étudiants aux UEs des parcours""" + + # Les acronymes des UEs + self.ues_to_acronymes = {ue.id: ue.acronyme for ue in self.ues_standards} + self.acronymes_sorted = sorted(self.ues_to_acronymes.values()) + """Les acronymes de UE triés par ordre alphabétique""" + + # Les compétences associées aux UEs (définies par les acronymes) + self.acronymes_ues_to_competences = {} + """Association acronyme d'UEs -> compétence""" + for ue in self.ues_standards: + assert ue.niveau_competence, ScoValueError( + "Des UEs ne sont pas rattachées à des compétences" + ) + nom = ue.niveau_competence.competence.titre + self.acronymes_ues_to_competences[ue.acronyme] = nom + self.competences_sorted = sorted( + list(set(self.acronymes_ues_to_competences.values())) + ) + """Compétences triées par nom""" + aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences) + pe_affichage.pe_print(f"--> UEs/Compétences : {aff}") + + # Les tags personnalisés et auto: + if "moyennes_tags" in options: + tags_dict = self._get_tags_dict(avec_moyennes_tags=options["moyennes_tags"]) + else: + tags_dict = self._get_tags_dict() + + pe_affichage.pe_print( + f"""--> {pe_affichage.aff_tags_par_categories(tags_dict)}""" + ) + self._check_tags(tags_dict) + + # Les coefficients pour le calcul de la moyenne générale, donnés par + # acronymes d'UE + self.matrice_coeffs_moy_gen = self._get_matrice_coeffs( + self.ues_inscr_parcours_df, self.ues_standards + ) + """DataFrame indiquant les coeffs des UEs par ordre alphabétique d'acronyme""" + profils_aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen) + pe_affichage.pe_print( + f"--> Moyenne générale calculée avec pour coeffs d'UEs : {profils_aff}" + ) + + # Les capitalisations (mask etuids x acronyme_ue valant True si capitalisée, False sinon) + self.capitalisations = self._get_capitalisations(self.ues_standards) + """DataFrame indiquant les UEs capitalisables d'un étudiant (etudids x )""" + + # Calcul des moyennes & les classements de chaque étudiant à chaque tag + self.moyennes_tags = {} + """Moyennes par tags (personnalisés ou 'but')""" + for tag in tags_dict["personnalises"]: + # pe_affichage.pe_print(f" -> Traitement du tag {tag}") + info_tag = tags_dict["personnalises"][tag] + # Les moyennes générales par UEs + moy_ues_tag = self.compute_moy_ues_tag(info_tag=info_tag, pole=None) + # Mémorise les moyennes + self.moyennes_tags[tag] = pe_moytag.MoyennesTag( + tag, + pe_moytag.CODE_MOY_UE, + moy_ues_tag, + self.matrice_coeffs_moy_gen, + ) + + # Ajoute les moyennes par UEs + la moyenne générale (but) + moy_gen = self.compute_moy_gen() + self.moyennes_tags["but"] = pe_moytag.MoyennesTag( + "but", + pe_moytag.CODE_MOY_UE, + moy_gen, + self.matrice_coeffs_moy_gen, + ) + + # Ajoute la moyenne générale par ressources + if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]: + moy_res_gen = self.compute_moy_ues_tag( + info_tag=None, pole=ModuleType.RESSOURCE + ) + self.moyennes_tags["ressources"] = pe_moytag.MoyennesTag( + "ressources", + pe_moytag.CODE_MOY_UE, + moy_res_gen, + self.matrice_coeffs_moy_gen, + ) + + # Ajoute la moyenne générale par saes + if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]: + moy_saes_gen = self.compute_moy_ues_tag(info_tag=None, pole=ModuleType.SAE) + self.moyennes_tags["saes"] = pe_moytag.MoyennesTag( + "saes", + pe_moytag.CODE_MOY_UE, + moy_saes_gen, + self.matrice_coeffs_moy_gen, + ) + + # Tous les tags + self.tags_sorted = self.get_all_significant_tags() + """Tags (personnalisés+compétences) par ordre alphabétique""" + + def get_repr(self, verbose=False) -> str: + """Nom affiché pour le semestre taggué, de la forme (par ex.): + + * S1#69 si verbose est False + * S1 FI 2023 si verbose est True + """ + if not verbose: + return f"{self.formsemestre}#{self.formsemestre.formsemestre_id}" + else: + return pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True) + + def _get_matrice_coeffs( + self, ues_inscr_parcours_df: pd.DataFrame, ues_standards: list[UniteEns] + ) -> pd.DataFrame: + """Renvoie un dataFrame donnant les coefficients à appliquer aux UEs + dans le calcul de la moyenne générale (toutes UEs confondues). + Prend en compte l'inscription des étudiants aux UEs en fonction de leur parcours + (cf. ues_inscr_parcours_df). + + Args: + ues_inscr_parcours_df: Les inscriptions des étudiants aux UEs + ues_standards: Les UEs standards à prendre en compte + + Returns: + Un dataFrame etudids x acronymes_UEs avec les coeffs des UEs + """ + matrice_coeffs_moy_gen = ues_inscr_parcours_df * [ + ue.ects for ue in ues_standards # if ue.type != UE_SPORT <= déjà supprimé + ] + matrice_coeffs_moy_gen.columns = [ + self.ues_to_acronymes[ue.id] for ue in ues_standards + ] + # Tri par etudids (dim 0) et par acronymes (dim 1) + matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index() + matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index(axis=1) + return matrice_coeffs_moy_gen + + def _get_capitalisations(self, ues_standards) -> pd.DataFrame: + """Renvoie un dataFrame résumant les UEs capitalisables par les + étudiants, d'après les décisions de jury (sous réserve qu'elles existent). + + Args: + ues_standards: Liste des UEs standards (notamment autres que le sport) + Returns: + Un dataFrame etudids x acronymes_UEs dont les valeurs sont ``True`` si l'UE + est capitalisable, ``False`` sinon + """ + capitalisations = pd.DataFrame( + False, index=self.etudids_sorted, columns=self.acronymes_sorted + ) + self.get_formsemestre_validations() # charge les validations + res_jury = self.validations + if res_jury: + for etud in self.etuds: + etudid = etud.etudid + decisions = res_jury.decisions_jury_ues.get(etudid, {}) + for ue in ues_standards: + if ue.id in decisions and decisions[ue.id]["code"] == sco_codes.ADM: + capitalisations.loc[etudid, ue.acronyme] = True + # Tri par etudis et par accronyme d'UE + capitalisations = capitalisations.sort_index() + capitalisations = capitalisations.sort_index(axis=1) + return capitalisations + + def compute_moy_ues_tag( + self, info_tag: dict[int, dict] = None, pole=None + ) -> pd.DataFrame: + """Calcule la moyenne par UE des étudiants pour un tag donné, + en ayant connaissance des informations sur le tag. + + info_tag détermine les modules pris en compte : + * si non `None`, seuls les modules rattachés au tag sont pris en compte + * si `None`, tous les modules (quelque soit leur rattachement au tag) sont pris + en compte (sert au calcul de la moyenne générale par ressource ou SAE) + + `pole` détermine les modules pris en compte : + + * si `pole` vaut `ModuleType.RESSOURCE`, seules les ressources sont prises + en compte (moyenne de ressources par UEs) + * si `pole` vaut `ModuleType.SAE`, seules les SAEs sont prises en compte + * si `pole` vaut `None` (ou toute autre valeur), + tous les modules sont pris en compte (moyenne d'UEs) + + + Les informations sur le tag sont un dictionnaire listant les modimpl_id rattachés au tag, + et pour chacun leur éventuel coefficient de **repondération**. + + Returns: + Le dataframe des moyennes du tag par UE + """ + modimpls_sorted = self.formsemestre.modimpls_sorted + + # Adaptation du mask de calcul des moyennes au tag visé + modimpls_mask = [] + for modimpl in modimpls_sorted: + module = modimpl.module # Le module + mask = module.ue.type == sco_codes.UE_STANDARD # Est-ce une UE stantard ? + if pole == ModuleType.RESSOURCE: + mask &= module.module_type == ModuleType.RESSOURCE + elif pole == ModuleType.SAE: + mask &= module.module_type == ModuleType.SAE + modimpls_mask += [mask] + + # Prise en compte du tag + if info_tag: + # Désactive tous les modules qui ne sont pas pris en compte pour ce tag + for i, modimpl in enumerate(modimpls_sorted): + if modimpl.moduleimpl_id not in info_tag: + modimpls_mask[i] = False + + # Applique la pondération des coefficients + modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy() + if info_tag: + for modimpl_id in info_tag: + ponderation = info_tag[modimpl_id]["ponderation"] + modimpl_coefs_ponderes_df[modimpl_id] *= ponderation + + # Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues) + moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc( + self.sem_cube, + self.etuds, + self.formsemestre.modimpls_sorted, + self.modimpl_inscr_df, + modimpl_coefs_ponderes_df, + modimpls_mask, + self.dispense_ues, + block=self.formsemestre.block_moyennes, + ) + + # Ne conserve que les UEs standards + colonnes = [ue.id for ue in self.ues_standards] + moyennes_ues_tag = moyennes_ues_tag[colonnes] + + # Transforme les UEs en acronyme + acronymes = [self.ues_to_acronymes[ue.id] for ue in self.ues_standards] + moyennes_ues_tag.columns = acronymes + + # Tri par etudids et par ordre alphabétique d'acronyme + moyennes_ues_tag = moyennes_ues_tag.sort_index() + moyennes_ues_tag = moyennes_ues_tag.sort_index(axis=1) + + return moyennes_ues_tag + + def compute_moy_gen(self): + """Récupère les moyennes des UEs pour le calcul de la moyenne générale, + en associant à chaque UE.id son acronyme (toutes UEs confondues) + """ + df_ues = pd.DataFrame( + {ue.id: self.etud_moy_ue[ue.id] for ue in self.ues_standards}, + index=self.etudids, + ) + # Transforme les UEs en acronyme + colonnes = df_ues.columns + acronymes = [self.ues_to_acronymes[col] for col in colonnes] + df_ues.columns = acronymes + + # Tri par ordre aphabétique de colonnes + df_ues.sort_index(axis=1) + + return df_ues + + def _get_tags_dict(self, avec_moyennes_tags=True): + """Renvoie les tags personnalisés (déduits des modules du semestre) + et les tags automatiques ('but'), et toutes leurs informations, + dans un dictionnaire de la forme : + + ``{"personnalises": {tag: info_sur_le_tag}, + "auto": {tag: {}}`` + + Returns: + Le dictionnaire structuré des tags ("personnalises" vs. "auto") + """ + dict_tags = {"personnalises": dict(), "auto": dict()} + + if avec_moyennes_tags: + # Les tags perso (seulement si l'option d'utiliser les tags perso est choisie) + dict_tags["personnalises"] = get_synthese_tags_personnalises_semestre( + self.formsemestre + ) + + # Les tags automatiques + # Déduit des compétences + # dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre) + # noms_tags_comp = list(set(dict_ues_competences.values())) + + # BUT + dict_tags["auto"] = {"but": {}, "ressources": {}, "saes": {}} + return dict_tags + + def _check_tags(self, dict_tags): + """Vérifie l'unicité des tags""" + noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys()))) + noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp + noms_tags = noms_tags_perso + noms_tags_auto + + intersection = list(set(noms_tags_perso) & set(noms_tags_auto)) + + if intersection: + liste_intersection = "\n".join( + [f"
  • {tag}
  • " for tag in intersection] + ) + s = "s" if len(intersection) > 1 else "" + message = f"""Erreur dans le module PE : Un des tags saisis dans votre + programme de formation fait parti des tags réservés. En particulier, + votre semestre {self.formsemestre.titre_annee()} + contient le{s} tag{s} réservé{s} suivant : + + Modifiez votre programme de formation pour le{s} supprimer. + Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études. + """ + raise ScoValueError(message) + + +def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre): + """Etant données les implémentations des modules du semestre (modimpls), + synthétise les tags renseignés dans le programme pédagogique & + associés aux modules du semestre, + en les associant aux modimpls qui les concernent (modimpl_id) et + au coeff de repondération fournie avec le tag (par défaut 1 si non indiquée)). + + Le dictionnaire fournit est de la forme : + + ``{ tag : { modimplid: {"modimpl": ModImpl, + "ponderation": coeff_de_reponderation} + } }`` + + Args: + formsemestre: Le formsemestre à la base de la recherche des tags + + Return: + Un dictionnaire décrivant les tags + """ + synthese_tags = {} + + # Instance des modules du semestre + modimpls = formsemestre.modimpls_sorted + + for modimpl in modimpls: + modimpl_id = modimpl.id + + # Liste des tags pour le module concerné + tags = sco_tag_module.module_tag_list(modimpl.module.id) + + # Traitement des tags recensés, chacun pouvant étant de la forme + # "mathématiques", "théorie", "pe:0", "maths:2" + for tag in tags: + # Extraction du nom du tag et du coeff de pondération + (tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag) + + # Ajout d'une clé pour le tag + if tagname not in synthese_tags: + synthese_tags[tagname] = {} + + # Ajout du module (modimpl) au tagname considéré + synthese_tags[tagname][modimpl_id] = { + "modimpl": modimpl, # les données sur le module + "ponderation": ponderation, # la pondération demandée pour le tag sur le module + } + + return synthese_tags diff --git a/app/pe/moys/pe_sxtag.py b/app/pe/moys/pe_sxtag.py new file mode 100644 index 000000000..6ddb1ee9f --- /dev/null +++ b/app/pe/moys/pe_sxtag.py @@ -0,0 +1,406 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +""" +Created on Fri Sep 9 09:15:05 2016 + +@author: barasc +""" + +from app.pe import pe_affichage, pe_comp +import app.pe.moys.pe_ressemtag as pe_ressemtag +import pandas as pd +import numpy as np + +from app.pe.moys import pe_moytag, pe_tabletags +import app.pe.rcss.pe_trajectoires as pe_trajectoires +from app.scodoc.sco_utils import ModuleType + + +class SxTag(pe_tabletags.TableTag): + def __init__( + self, + sxtag_id: (str, int), + semx: pe_trajectoires.SemX, + ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag], + ): + """Calcule les moyennes/classements par tag d'un semestre de type 'Sx' + (par ex. 'S1', 'S2', ...) représentés par acronyme d'UE. + + Il représente : + + * pour les étudiants *non redoublants* : moyennes/classements + du semestre suivi + * pour les étudiants *redoublants* : une fusion des moyennes/classements + dans les (2) 'Sx' qu'il a suivi, en exploitant les informations de capitalisation : + meilleure moyenne entre l'UE capitalisée et l'UE refaite (la notion de meilleure + s'appliquant à la moyenne d'UE) + + Un SxTag (regroupant potentiellement plusieurs semestres) est identifié + par un tuple ``(Sx, fid)`` où : + + * ``x`` est le rang (semestre_id) du semestre + * ``fid`` le formsemestre_id du semestre final (le plus récent) du regroupement. + + Les **tags**, les **UE** et les inscriptions aux UEs (pour les étudiants) + considérés sont uniquement ceux du semestre final. + + Args: + sxtag_id: L'identifiant de SxTag + ressembuttags: Un dictionnaire de la forme `{fid: ResSemBUTTag(fid)}` donnant + les semestres à regrouper et les résultats/moyennes par tag des + semestres + """ + pe_tabletags.TableTag.__init__(self) + + assert sxtag_id and len(sxtag_id) == 2 and sxtag_id[1] in ressembuttags + + self.sxtag_id: (str, int) = sxtag_id + """Identifiant du SxTag de la forme (nom_Sx, fid_semestre_final)""" + assert ( + len(self.sxtag_id) == 2 + and isinstance(self.sxtag_id[0], str) + and isinstance(self.sxtag_id[1], int) + ), "Format de l'identifiant du SxTag non respecté" + + self.agregat = sxtag_id[0] + """Nom de l'aggrégat du RCS""" + + self.semx = semx + """Le SemX sur lequel il s'appuie""" + assert semx.rcs_id == sxtag_id, "Problème de correspondance SxTag/SemX" + + # Les resultats des semestres taggués à prendre en compte dans le SemX + self.ressembuttags = { + fid: ressembuttags[fid] for fid in semx.semestres_aggreges + } + """Les ResSemBUTTags à regrouper dans le SxTag""" + + # Les données du semestre final + self.fid_final = sxtag_id[1] + self.ressembuttag_final = ressembuttags[self.fid_final] + """Le ResSemBUTTag final""" + + # Ajoute les etudids et les états civils + self.etuds = self.ressembuttag_final.etuds + """Les étudiants (extraits du ReSemBUTTag final)""" + self.add_etuds(self.etuds) + self.etudids_sorted = sorted(self.etudids) + """Les etudids triés""" + + # Affichage + pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}") + + # Les tags + self.tags_sorted = self.ressembuttag_final.tags_sorted + """Tags (extraits du ReSemBUTTag final)""" + aff_tag = pe_affichage.repr_tags(self.tags_sorted) + pe_affichage.pe_print(f"--> Tags : {aff_tag}") + + # Les UE données par leur acronyme + self.acronymes_sorted = self.ressembuttag_final.acronymes_sorted + """Les acronymes des UEs (extraits du ResSemBUTTag final)""" + + # L'association UE-compétences extraites du dernier semestre + self.acronymes_ues_to_competences = ( + self.ressembuttag_final.acronymes_ues_to_competences + ) + """L'association acronyme d'UEs -> compétence""" + self.competences_sorted = sorted(self.acronymes_ues_to_competences.values()) + """Les compétences triées par nom""" + + aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences) + pe_affichage.pe_print(f"--> UEs/Compétences : {aff}") + + # Les coeffs pour la moyenne générale (traduisant également l'inscription + # des étudiants aux UEs) (etudids_sorted x acronymes_ues_sorted) + self.matrice_coeffs_moy_gen = self.ressembuttag_final.matrice_coeffs_moy_gen + """La matrice des coeffs pour la moyenne générale""" + aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen) + pe_affichage.pe_print( + f"--> Moyenne générale calculée avec pour coeffs d'UEs : {aff}" + ) + + # Masque des inscriptions et des capitalisations + self.masque_df = None + """Le DataFrame traduisant les capitalisations des différents semestres""" + self.masque_df, masque_cube = compute_masques_capitalisation_cube( + self.etudids_sorted, + self.acronymes_sorted, + self.ressembuttags, + self.fid_final, + ) + pe_affichage.aff_capitalisations( + self.etuds, + self.ressembuttags, + self.fid_final, + self.acronymes_sorted, + self.masque_df, + ) + + # Les moyennes par tag + self.moyennes_tags: dict[str, pd.DataFrame] = {} + """Moyennes aux UEs (identifiées par leur acronyme) des différents tags""" + + if self.tags_sorted: + pe_affichage.pe_print("--> Calcul des moyennes par tags :") + + for tag in self.tags_sorted: + pe_affichage.pe_print(f" > MoyTag 👜{tag}") + + # Masque des inscriptions aux UEs (extraits de la matrice de coefficients) + inscr_mask: np.array = ~np.isnan(self.matrice_coeffs_moy_gen.to_numpy()) + + # Moyennes (tous modules confondus) + if not self.has_notes_tag(tag): + pe_affichage.pe_print( + f" --> Semestre (final) actuellement sans notes" + ) + matrice_moys_ues = pd.DataFrame( + np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted + ) + else: + # Moyennes tous modules confondus + ### Cube de note etudids x UEs tous modules confondus + notes_df_gen, notes_cube_gen = self.compute_notes_ues_cube(tag) + + # DataFrame des moyennes (tous modules confondus) + matrice_moys_ues = self.compute_notes_ues( + notes_cube_gen, masque_cube, inscr_mask + ) + + # Mémorise les infos pour la moyenne au tag + self.moyennes_tags[tag] = pe_moytag.MoyennesTag( + tag, + pe_moytag.CODE_MOY_UE, + matrice_moys_ues, + self.matrice_coeffs_moy_gen, + ) + + # Affichage de debug + aff = pe_affichage.repr_profil_coeffs( + self.matrice_coeffs_moy_gen, with_index=True + ) + pe_affichage.pe_print(f" > Moyenne générale calculée avec : {aff}") + + def has_notes_tag(self, tag): + """Détermine si le SxTag, pour un tag donné, est en cours d'évaluation. + Si oui, n'a pas (encore) de notes dans le resformsemestre final. + + Args: + tag: Le tag visé + + Returns: + True si a des notes, False sinon + """ + moy_tag_dernier_sem = self.ressembuttag_final.moyennes_tags[tag] + return moy_tag_dernier_sem.has_notes() + + def __eq__(self, other): + """Egalité de 2 SxTag sur la base de leur identifiant""" + return self.sxtag_id == other.sxtag_id + + def get_repr(self, verbose=False) -> str: + """Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle + est basée)""" + if verbose: + return f"SXTag basé sur {self.semx.get_repr()}" + else: + # affichage = [str(fid) for fid in self.ressembuttags] + return f"SXTag {self.agregat}#{self.fid_final}" + + def compute_notes_ues_cube(self, tag) -> (pd.DataFrame, np.array): + """Construit le cube de notes des UEs (etudid x accronyme_ue x semestre_aggregé) + nécessaire au calcul des moyennes du tag pour le RCS Sx. + (Renvoie également le dataframe associé pour debug). + + Args: + tag: Le tag considéré (personalisé ou "but") + """ + # Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2) + # etudids_sorted = etudids_sorted + # acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()]) + semestres_id = list(self.ressembuttags.keys()) + + dfs = {} + + for frmsem_id in semestres_id: + # Partant d'un dataframe vierge + df = pd.DataFrame( + np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted + ) + + # Charge les notes du semestre tag + sem_tag = self.ressembuttags[frmsem_id] + moys_tag = sem_tag.moyennes_tags[tag] + notes = moys_tag.matrice_notes_gen # dataframe etudids x ues + + # les étudiants et les acronymes communs + etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs( + df, notes + ) + + # Recopie + df.loc[etudids_communs, acronymes_communs] = notes.loc[ + etudids_communs, acronymes_communs + ] + + # Supprime tout ce qui n'est pas numérique + for col in df.columns: + df[col] = pd.to_numeric(df[col], errors="coerce") + + # Stocke le df + dfs[frmsem_id] = df + + """Réunit les notes sous forme d'un cube etudids x ues x semestres""" + semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs] + etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1) + return dfs, etudids_x_ues_x_semestres + + def compute_notes_ues( + self, + set_cube: np.array, + masque_cube: np.array, + inscr_mask: np.array, + ) -> pd.DataFrame: + """Calcule la moyenne par UEs à un tag donné en prenant la note maximum (UE + par UE) obtenue par un étudiant à un semestre. + + Args: + set_cube: notes moyennes aux modules ndarray + (semestre_ids x etudids x UEs), des floats avec des NaN + masque_cube: masque indiquant si la note doit être prise en compte ndarray + (semestre_ids x etudids x UEs), des 1.0 ou des 0.0 + inscr_mask: masque etudids x UE traduisant les inscriptions des + étudiants aux UE (du semestre terminal) + Returns: + Un DataFrame avec pour columns les moyennes par ues, + et pour rows les etudid + """ + # etudids_sorted: liste des étudiants (dim. 0 du cube) trié par etudid + # acronymes_sorted: liste des acronymes des ues (dim. 1 du cube) trié par acronyme + nb_etuds, nb_ues, nb_semestres = set_cube.shape + nb_etuds_mask, nb_ues_mask = inscr_mask.shape + # assert nb_etuds == len(self.etudids_sorted) + # assert nb_ues == len(self.acronymes_sorted) + # assert nb_etuds == nb_etuds_mask + # assert nb_ues == nb_ues_mask + + # Entrées à garder dans le cube en fonction du masque d'inscription aux UEs du parcours + inscr_mask_3D = np.stack([inscr_mask] * nb_semestres, axis=-1) + set_cube = set_cube * inscr_mask_3D + + # Entrées à garder en fonction des UEs capitalisées ou non + set_cube = set_cube * masque_cube + + # Quelles entrées du cube contiennent des notes ? + mask = ~np.isnan(set_cube) + + # Enlève les NaN du cube pour les entrées manquantes : NaN -> -1.0 + set_cube_no_nan = np.nan_to_num(set_cube, nan=-1.0) + + # Les moyennes par ues + # TODO: Pour l'instant un max sans prise en compte des UE capitalisées + etud_moy = np.max(set_cube_no_nan, axis=2) + + # Fix les max non calculé -1 -> NaN + etud_moy[etud_moy < 0] = np.NaN + + # Le dataFrame + etud_moy_tag_df = pd.DataFrame( + etud_moy, + index=self.etudids_sorted, # les etudids + columns=self.acronymes_sorted, # les acronymes d'UEs + ) + + etud_moy_tag_df = etud_moy_tag_df.fillna(np.nan) + + return etud_moy_tag_df + + +def compute_masques_capitalisation_cube( + etudids_sorted: list[int], + acronymes_sorted: list[str], + ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag], + formsemestre_id_final: int, +) -> (pd.DataFrame, np.array): + """Construit le cube traduisant les masques des UEs à prendre en compte dans le calcul + des moyennes, en utilisant le dataFrame de capitalisations de chaque ResSemBUTTag + + Ces masques contiennent : 1 si la note doit être prise en compte, 0 sinon + + Le masque des UEs à prendre en compte correspondant au semestre final (identifié par + son formsemestre_id_final) est systématiquement à 1 (puisque les résultats + de ce semestre doivent systématiquement + être pris en compte notamment pour les étudiants non redoublant). + + Args: + etudids_sorted: La liste des etudids triés par ordre croissant (dim 0) + acronymes_sorted: La liste des acronymes de UEs triés par acronyme croissant (dim 1) + ressembuttags: Le dictionnaire des résultats de semestres BUT (tous tags confondus) + formsemestre_id_final: L'identifiant du formsemestre_id_final + """ + # Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2) + # etudids_sorted = etudids_sorted + # acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()]) + semestres_id = list(ressembuttags.keys()) + + dfs = {} + + for frmsem_id in semestres_id: + # Partant d'un dataframe contenant des 1.0 + if frmsem_id == formsemestre_id_final: + df = pd.DataFrame(1.0, index=etudids_sorted, columns=acronymes_sorted) + else: # semestres redoublés + df = pd.DataFrame(0.0, index=etudids_sorted, columns=acronymes_sorted) + + # Traitement des capitalisations : remplace les infos de capitalisations par les coeff 1 ou 0 + capitalisations = ressembuttags[frmsem_id].capitalisations + capitalisations = capitalisations.replace(True, 1.0).replace(False, 0.0) + + # Met à 0 les coeffs des UEs non capitalisées pour les étudiants + # inscrits dans les 2 semestres: 1.0*False => 0.0 + etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs( + df, capitalisations + ) + + df.loc[etudids_communs, acronymes_communs] = capitalisations.loc[ + etudids_communs, acronymes_communs + ] + + # Stocke le df + dfs[frmsem_id] = df + + """Réunit les notes sous forme d'un cube etudids x ues x semestres""" + semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs] + etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1) + return dfs, etudids_x_ues_x_semestres diff --git a/app/pe/moys/pe_tabletags.py b/app/pe/moys/pe_tabletags.py new file mode 100644 index 000000000..d726a1493 --- /dev/null +++ b/app/pe/moys/pe_tabletags.py @@ -0,0 +1,203 @@ +# -*- pole: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + + +""" +Created on Thu Sep 8 09:36:33 2016 + +@author: barasc +""" + +import pandas as pd + +from app.models import Identite +from app.pe.moys import pe_moytag + +TAGS_RESERVES = ["but"] + +CHAMPS_ADMINISTRATIFS = ["Civilité", "Nom", "Prénom"] + + +class TableTag(object): + def __init__(self): + """Classe centralisant différentes méthodes communes aux + SemestreTag, TrajectoireTag, AggregatInterclassTag + """ + # Les étudiants + # self.etuds: list[Identite] = None # A venir + """Les étudiants""" + # self.etudids: list[int] = {} + """Les etudids""" + + def add_etuds(self, etuds: list[Identite]): + """Mémorise les informations sur les étudiants + + Args: + etuds: la liste des identités de l'étudiant + """ + # self.etuds = etuds + self.etudids = list({etud.etudid for etud in etuds}) + + def get_all_significant_tags(self): + """Liste des tags de la table, triée par ordre alphabétique, + extraite des clés du dictionnaire ``moyennes_tags``, en ne + considérant que les moyennes ayant des notes. + + Returns: + Liste de tags triés par ordre alphabétique + """ + tags = [] + tag: str = "" + moytag: pe_moytag.MoyennesTag = None + for tag, moytag in self.moyennes_tags.items(): + if moytag.has_notes(): + tags.append(tag) + return sorted(tags) + + def to_df( + self, + administratif=True, + aggregat=None, + tags_cibles=None, + cohorte=None, + options={"min_max_moy": True}, + ) -> pd.DataFrame: + """Renvoie un dataframe listant toutes les données + des moyennes/classements/nb_inscrits/min/max/moy + des étudiants aux différents tags. + + tags_cibles limitent le dataframe aux tags indiqués + type_colonnes indiquent si les colonnes doivent être passées en multiindex + + Args: + administratif: Indique si les données administratives sont incluses + aggregat: l'aggrégat représenté + tags_cibles: la liste des tags ciblés + cohorte: la cohorte représentée + Returns: + Le dataframe complet de synthèse + """ + if not self.is_significatif(): + return None + + # Les tags visés + tags_tries = self.get_all_significant_tags() + if not tags_cibles: + tags_cibles = tags_tries + tags_cibles = sorted(tags_cibles) + + # Les tags visés avec des notes + + # Les étudiants visés + if administratif: + df = df_administratif(self.etuds, aggregat=aggregat, cohorte=cohorte) + else: + df = pd.DataFrame(index=self.etudids) + + # Ajout des données par tags + for tag in tags_cibles: + if tag in self.moyennes_tags: + moy_tag_df = self.moyennes_tags[tag].to_df( + aggregat=aggregat, cohorte=cohorte, options=options + ) + df = df.join(moy_tag_df) + + # Tri par nom, prénom + if administratif: + colonnes_tries = [ + _get_champ_administratif(champ, aggregat=aggregat, cohorte=cohorte) + for champ in CHAMPS_ADMINISTRATIFS[1:] + ] # Nom + Prénom + df = df.sort_values(by=colonnes_tries) + return df + + def has_etuds(self): + """Indique si un tabletag contient des étudiants""" + return len(self.etuds) > 0 + + def is_significatif(self): + """Indique si une tabletag a des données""" + # A des étudiants + if not self.etuds: + return False + # A des tags avec des notes + tags_tries = self.get_all_significant_tags() + if not tags_tries: + return False + return True + + +def _get_champ_administratif(champ, aggregat=None, cohorte=None): + """Pour un champ donné, renvoie l'index (ou le multindex) + à intégrer au dataframe""" + liste = [] + if aggregat != None: + liste += [aggregat] + liste += ["Administratif", "Identité"] + if cohorte != None: + liste += [champ] + liste += [champ] + return "|".join(liste) + + +def df_administratif( + etuds: list[Identite], aggregat=None, cohorte=None +) -> pd.DataFrame: + """Renvoie un dataframe donnant les données administratives + des étudiants du TableTag + + Args: + etuds: Identité des étudiants générant les données administratives + """ + identites = {etud.etudid: etud for etud in etuds} + + donnees = {} + etud: Identite = None + for etudid, etud in identites.items(): + data = { + CHAMPS_ADMINISTRATIFS[0]: etud.civilite_str, + CHAMPS_ADMINISTRATIFS[1]: etud.nom, + CHAMPS_ADMINISTRATIFS[2]: etud.prenom_str, + } + donnees[etudid] = { + _get_champ_administratif(champ, aggregat, cohorte): data[champ] + for champ in CHAMPS_ADMINISTRATIFS + } + + colonnes = [ + _get_champ_administratif(champ, aggregat, cohorte) + for champ in CHAMPS_ADMINISTRATIFS + ] + + df = pd.DataFrame.from_dict(donnees, orient="index", columns=colonnes) + df = df.sort_values(by=colonnes[1:]) + return df diff --git a/app/pe/pe_affichage.py b/app/pe/pe_affichage.py index 50f2e2ab7..4ed7c7d3d 100644 --- a/app/pe/pe_affichage.py +++ b/app/pe/pe_affichage.py @@ -8,6 +8,7 @@ from flask import g from app import log +from app.pe.rcss import pe_rcs PE_DEBUG = False @@ -20,17 +21,19 @@ def pe_start_log() -> list[str]: return g.scodoc_pe_log -def pe_print(*a): +def pe_print(*a, **cles): "Log (or print in PE_DEBUG mode) and store in g" - lines = getattr(g, "scodoc_pe_log") - if lines is None: - lines = pe_start_log() - msg = " ".join(a) - lines.append(msg) if PE_DEBUG: + msg = " ".join(a) print(msg) else: - log(msg) + lines = getattr(g, "scodoc_pe_log") + if lines is None: + lines = pe_start_log() + msg = " ".join(a) + lines.append(msg) + if "info" in cles: + log(msg) def pe_get_log() -> str: @@ -40,5 +43,192 @@ def pe_get_log() -> str: # Affichage dans le tableur pe en cas d'absence de notes SANS_NOTE = "-" -NOM_STAT_GROUPE = "statistiques du groupe" -NOM_STAT_PROMO = "statistiques de la promo" + + +def repr_profil_coeffs(matrice_coeffs_moy_gen, with_index=False): + """Affiche les différents types de coefficients (appelés profil) + d'une matrice_coeffs_moy_gen (pour debug) + """ + + # Les profils des coeffs d'UE (pour debug) + profils = [] + index_a_profils = {} + for i in matrice_coeffs_moy_gen.index: + val = matrice_coeffs_moy_gen.loc[i].fillna("-") + val = " | ".join([str(v) for v in val]) + if val not in profils: + profils += [val] + index_a_profils[val] = [str(i)] + else: + index_a_profils[val] += [str(i)] + + # L'affichage + if len(profils) > 1: + if with_index: + elmts = [ + " " * 10 + + prof + + " (par ex. " + + ", ".join(index_a_profils[prof][:10]) + + ")" + for prof in profils + ] + else: + elmts = [" " * 10 + prof for prof in profils] + profils_aff = "\n" + "\n".join(elmts) + else: + profils_aff = "\n".join(profils) + return profils_aff + + +def repr_asso_ue_comp(acronymes_ues_to_competences): + """Représentation textuelle de l'association UE -> Compétences + fournies dans acronymes_ues_to_competences + """ + champs = acronymes_ues_to_competences.keys() + champs = sorted(champs) + aff_comp = [] + for acro in champs: + aff_comp += [f"📍{acro} (∈ 💡{acronymes_ues_to_competences[acro]})"] + return ", ".join(aff_comp) + + +def aff_UEs(champs): + """Représentation textuelle des UEs fournies dans `champs`""" + champs_tries = sorted(champs) + aff_comp = [] + + for comp in champs_tries: + aff_comp += ["📍" + comp] + return ", ".join(aff_comp) + + +def aff_competences(champs): + """Affiche les compétences""" + champs_tries = sorted(champs) + aff_comp = [] + + for comp in champs_tries: + aff_comp += ["💡" + comp] + return ", ".join(aff_comp) + + +def repr_tags(tags): + """Affiche les tags""" + tags_tries = sorted(tags) + aff_tag = ["👜" + tag for tag in tags_tries] + return ", ".join(aff_tag) + + +def aff_tags_par_categories(dict_tags): + """Etant donné un dictionnaire de tags, triés + par catégorie (ici "personnalisés" ou "auto") + représentation textuelle des tags + """ + noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys()))) + noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp + if noms_tags_perso: + aff_tags_perso = ", ".join([f"👜{nom}" for nom in noms_tags_perso]) + aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto]) + return f"Tags du programme de formation : {aff_tags_perso} + Automatiques : {aff_tags_auto}" + else: + aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto]) + return f"Tags automatiques {aff_tags_auto} (aucun tag personnalisé)" + + # Affichage + + +def aff_trajectoires_suivies_par_etudiants(etudiants): + """Affiche les trajectoires (regroupement de (form)semestres) + amenant un étudiant du S1 à un semestre final""" + # Affichage pour debug + etudiants_ids = etudiants.etudiants_ids + jeunes = list(enumerate(etudiants_ids)) + for no_etud, etudid in jeunes: + etat = "⛔" if etudid in etudiants.abandons_ids else "✅" + + pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} (#{etudid}) :") + trajectoires = etudiants.trajectoires[etudid] + for nom_rcs, rcs in trajectoires.items(): + if rcs: + pe_print(f" > RCS ⏯️{nom_rcs}: {rcs.get_repr()}") + + +def aff_semXs_suivis_par_etudiants(etudiants): + """Affiche les SemX (regroupement de semestres de type Sx) + amenant un étudiant à valider un Sx""" + etudiants_ids = etudiants.etudiants_ids + jeunes = list(enumerate(etudiants_ids)) + + for no_etud, etudid in jeunes: + etat = "⛔" if etudid in etudiants.abandons_ids else "✅" + pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} :") + for nom_rcs, rcs in etudiants.semXs[etudid].items(): + if rcs: + pe_print(f" > SemX ⏯️{nom_rcs}: {rcs.get_repr()}") + + vides = [] + for nom_rcs in pe_rcs.TOUS_LES_SEMESTRES: + les_semX_suivis = [] + for no_etud, etudid in jeunes: + if etudiants.semXs[etudid][nom_rcs]: + les_semX_suivis.append(etudiants.semXs[etudid][nom_rcs]) + if not les_semX_suivis: + vides += [nom_rcs] + vides = sorted(list(set(vides))) + pe_print(f"⚠️ SemX sans données : {', '.join(vides)}") + + +def aff_capitalisations(etuds, ressembuttags, fid_final, acronymes_sorted, masque_df): + """Affichage des capitalisations du sxtag pour debug""" + aff_cap = [] + for etud in etuds: + cap = [] + for frmsem_id in ressembuttags: + if frmsem_id != fid_final: + for accr in acronymes_sorted: + if masque_df[frmsem_id].loc[etud.etudid, accr] > 0.0: + cap += [accr] + if cap: + aff_cap += [f" > {etud.nomprenom} : {', '.join(cap)}"] + if aff_cap: + pe_print(f"--> ⚠️ Capitalisations :") + pe_print("\n".join(aff_cap)) + + +def repr_comp_et_ues(acronymes_ues_to_competences): + """Affichage pour debug""" + aff_comp = [] + competences_sorted = sorted(acronymes_ues_to_competences.keys()) + for comp in competences_sorted: + liste = [] + for acro in acronymes_ues_to_competences: + if acronymes_ues_to_competences[acro] == comp: + liste += ["📍" + acro] + aff_comp += [f" 💡{comp} (⇔ {', '.join(liste)})"] + return "\n".join(aff_comp) + + +def aff_rcsemxs_suivis_par_etudiants(etudiants): + """Affiche les RCSemX (regroupement de SemX) + amenant un étudiant du S1 à un Sx""" + etudiants_ids = etudiants.etudiants_ids + jeunes = list(enumerate(etudiants_ids)) + + for no_etud, etudid in jeunes: + etat = "⛔" if etudid in etudiants.abandons_ids else "✅" + pe_print(f"-> {etat} {etudiants.identites[etudid].nomprenom} :") + for nom_rcs, rcs in etudiants.rcsemXs[etudid].items(): + if rcs: + pe_print(f" > RCSemX ⏯️{nom_rcs}: {rcs.get_repr()}") + + vides = [] + for nom_rcs in pe_rcs.TOUS_LES_RCS: + les_rcssemX_suivis = [] + for no_etud, etudid in jeunes: + if etudiants.rcsemXs[etudid][nom_rcs]: + les_rcssemX_suivis.append(etudiants.rcsemXs[etudid][nom_rcs]) + if not les_rcssemX_suivis: + vides += [nom_rcs] + vides = sorted(list(set(vides))) + pe_print(f"⚠️ RCSemX vides : {', '.join(vides)}") diff --git a/app/pe/pe_comp.py b/app/pe/pe_comp.py index 67805dfa4..0ab973f4c 100644 --- a/app/pe/pe_comp.py +++ b/app/pe/pe_comp.py @@ -41,13 +41,13 @@ import datetime import re import unicodedata - +import pandas as pd from flask import g import app.scodoc.sco_utils as scu from app.models import FormSemestre -from app.pe.pe_rcs import TYPES_RCS +from app.pe.rcss.pe_rcs import TYPES_RCS from app.scodoc import sco_formsemestre from app.scodoc.sco_logos import find_logo @@ -284,3 +284,56 @@ def get_cosemestres_diplomants(annee_diplome: int) -> dict[int, FormSemestre]: cosemestres[fid] = cosem return cosemestres + + +def tri_semestres_par_rang(cosemestres: dict[int, FormSemestre]): + """Partant d'un dictionnaire de cosemestres, les tri par rang (semestre_id) dans un + dictionnaire {rang: [liste des semestres du dit rang]}""" + cosemestres_tries = {} + for sem in cosemestres.values(): + cosemestres_tries[sem.semestre_id] = cosemestres_tries.get( + sem.semestre_id, [] + ) + [sem] + return cosemestres_tries + + +def find_index_and_columns_communs( + df1: pd.DataFrame, df2: pd.DataFrame +) -> (list, list): + """Partant de 2 DataFrames ``df1`` et ``df2``, renvoie les indices de lignes + et de colonnes, communes aux 2 dataframes + + Args: + df1: Un dataFrame + df2: Un dataFrame + Returns: + Le tuple formé par la liste des indices de lignes communs et la liste des indices + de colonnes communes entre les 2 dataFrames + """ + indices1 = df1.index + indices2 = df2.index + indices_communs = list(df1.index.intersection(df2.index)) + colonnes1 = df1.columns + colonnes2 = df2.columns + colonnes_communes = list(set(colonnes1) & set(colonnes2)) + return indices_communs, colonnes_communes + + +def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre: + """Renvoie le dernier semestre en **date de fin** d'un dictionnaire + de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``. + + Args: + semestres: Un dictionnaire de semestres + + Return: + Le FormSemestre du semestre le plus récent + """ + if semestres: + fid_dernier_semestre = list(semestres.keys())[0] + dernier_semestre: FormSemestre = semestres[fid_dernier_semestre] + for fid in semestres: + if semestres[fid].date_fin > dernier_semestre.date_fin: + dernier_semestre = semestres[fid] + return dernier_semestre + return None diff --git a/app/pe/pe_etudiant.py b/app/pe/pe_etudiant.py index 9bcb6c495..7bd65efda 100644 --- a/app/pe/pe_etudiant.py +++ b/app/pe/pe_etudiant.py @@ -5,7 +5,7 @@ # # Gestion scolarite IUT # -# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# Copyright (c) 1999 - 2024 Emmanuel Viennet. c All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -37,8 +37,10 @@ Created on 17/01/2024 """ import pandas as pd +from app import ScoValueError from app.models import FormSemestre, Identite, Formation from app.pe import pe_comp, pe_affichage +from app.pe.rcss import pe_rcs from app.scodoc import codes_cursus from app.scodoc import sco_utils as scu from app.comp.res_sem import load_formsemestre_results @@ -55,16 +57,20 @@ class EtudiantsJuryPE: self.annee_diplome = annee_diplome """L'année du diplôme""" - self.identites: dict[int, Identite] = {} # ex. ETUDINFO_DICT - "Les identités des étudiants traités pour le jury" + self.identites: dict[int:Identite] = {} # ex. ETUDINFO_DICT + """Les identités des étudiants traités pour le jury""" - self.cursus: dict[int, dict] = {} - "Les cursus (semestres suivis, abandons) des étudiants" + self.cursus: dict[int:dict] = {} + """Les cursus (semestres suivis, abandons) des étudiants""" - self.trajectoires = {} - """Les trajectoires/chemins de semestres suivis par les étudiants - pour atteindre un aggrégat donné - (par ex: 3S=S1+S2+S3 à prendre en compte avec d'éventuels redoublements)""" + self.trajectoires: dict[int:dict] = {} + """Les trajectoires (regroupement cohérents de semestres) suivis par les étudiants""" + + self.semXs: dict[int:dict] = {} + """Les semXs (RCS de type Sx) suivis par chaque étudiant""" + + self.rcsemXs: dict[int:dict] = {} + """Les RC de SemXs (RCS de type Sx, xA, xS) suivis par chaque étudiant""" self.etudiants_diplomes = {} """Les identités des étudiants à considérer au jury (ceux qui seront effectivement @@ -99,27 +105,26 @@ class EtudiantsJuryPE: self.cosemestres = cosemestres pe_affichage.pe_print( - f"1) Recherche des coSemestres -> {len(cosemestres)} trouvés" + f"1) Recherche des cosemestres -> {len(cosemestres)} trouvés", info=True ) - pe_affichage.pe_print("2) Liste des étudiants dans les différents co-semestres") - self.etudiants_ids = get_etudiants_dans_semestres(cosemestres) pe_affichage.pe_print( - f" => {len(self.etudiants_ids)} étudiants trouvés dans les cosemestres" + "2) Liste des étudiants dans les différents cosemestres", info=True + ) + etudiants_ids = get_etudiants_dans_semestres(cosemestres) + pe_affichage.pe_print( + f" => {len(etudiants_ids)} étudiants trouvés dans les cosemestres", + info=True, ) # Analyse des parcours étudiants pour déterminer leur année effective de diplome # avec prise en compte des redoublements, des abandons, .... - pe_affichage.pe_print("3) Analyse des parcours individuels des étudiants") + pe_affichage.pe_print( + "3) Analyse des parcours individuels des étudiants", info=True + ) - for etudid in self.etudiants_ids: - self.identites[etudid] = Identite.get_etud(etudid) - - # Analyse son cursus - self.analyse_etat_etudiant(etudid, cosemestres) - - # Analyse son parcours pour atteindre chaque semestre de la formation - self.structure_cursus_etudiant(etudid) + # Ajoute une liste d'étudiants + self.add_etudiants(etudiants_ids) # Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris self.etudiants_diplomes = self.get_etudiants_diplomes() @@ -134,23 +139,35 @@ class EtudiantsJuryPE: # Les identifiants des étudiants ayant redoublés ou ayant abandonnés # Synthèse + pe_affichage.pe_print(f"4) Bilan", info=True) pe_affichage.pe_print( - f" => {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}" + f"--> {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}", + info=True, ) nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_diplomes) assert nbre_abandons == len(self.abandons_ids) pe_affichage.pe_print( - f" => {nbre_abandons} étudiants non considérés (redoublement, réorientation, abandon" + f"--> {nbre_abandons} étudiants traités mais non diplômés (redoublement, réorientation, abandon)" ) - # pe_affichage.pe_print( - # " => quelques étudiants futurs diplômés : " - # + ", ".join([str(etudid) for etudid in list(self.etudiants_diplomes)[:10]]) - # ) - # pe_affichage.pe_print( - # " => semestres dont il faut calculer les moyennes : " - # + ", ".join([str(fid) for fid in list(self.formsemestres_jury_ids)]) - # ) + + def add_etudiants(self, etudiants_ids): + """Ajoute une liste d'étudiants aux données du jury""" + nbre_etudiants_ajoutes = 0 + for etudid in etudiants_ids: + if etudid not in self.identites: + nbre_etudiants_ajoutes += 1 + + # L'identité de l'étudiant + self.identites[etudid] = Identite.get_etud(etudid) + + # Analyse son cursus + self.analyse_etat_etudiant(etudid, self.cosemestres) + + # Analyse son parcours pour atteindre chaque semestre de la formation + self.structure_cursus_etudiant(etudid) + self.etudiants_ids = set(self.identites.keys()) + return nbre_etudiants_ajoutes def get_etudiants_diplomes(self) -> dict[int, Identite]: """Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}` @@ -198,8 +215,11 @@ class EtudiantsJuryPE: * à insérer une entrée dans ``self.cursus`` pour mémoriser son identité, avec son nom, prénom, etc... - * à analyser son parcours, pour déterminer s'il n'a (ou non) abandonné l'IUT en cours de - route (cf. clé abandon) + * à analyser son parcours, pour déterminer s'il a démissionné, redoublé (autre année de diplôme) + ou a abandonné l'IUT en cours de route (cf. clé abandon). Un étudiant est considéré + en abandon si connaissant son dernier semestre (par ex. un S3) il n'est pas systématiquement + inscrit à l'un des S4, S5 ou S6 existants dans les cosemestres. + Args: etudid: L'etudid d'un étudiant, à ajouter à ceux traiter par le jury @@ -217,11 +237,19 @@ class EtudiantsJuryPE: if formsemestre.formation.is_apc() } + # Le parcours final + parcour = formsemestres[0].etuds_inscriptions[etudid].parcour + if parcour: + libelle = parcour.libelle + else: + libelle = None + self.cursus[etudid] = { "etudid": etudid, # les infos sur l'étudiant "etat_civil": identite.etat_civil, # Ajout à la table jury "nom": identite.nom, "entree": formsemestres[-1].date_debut.year, # La date d'entrée à l'IUT + "parcours": libelle, # Le parcours final "diplome": get_annee_diplome( identite ), # Le date prévisionnelle de son diplôme @@ -232,35 +260,24 @@ class EtudiantsJuryPE: "abandon": False, # va être traité en dessous } - # Est-il démissionnaire : charge son dernier semestre pour connaitre son état ? - dernier_semes_etudiant = formsemestres[0] - res = load_formsemestre_results(dernier_semes_etudiant) - etud_etat = res.get_etud_etat(etudid) - if etud_etat == scu.DEMISSION: - self.cursus[etudid]["abandon"] |= True - else: - # Est-il réorienté ou a-t-il arrêté volontairement sa formation ? - self.cursus[etudid]["abandon"] |= arret_de_formation(identite, cosemestres) + # Si l'étudiant est succeptible d'être diplomé + if self.cursus[etudid]["diplome"] == self.annee_diplome: + # Est-il démissionnaire : charge son dernier semestre pour connaitre son état ? + dernier_semes_etudiant = formsemestres[0] + res = load_formsemestre_results(dernier_semes_etudiant) + etud_etat = res.get_etud_etat(etudid) + if etud_etat == scu.DEMISSION: + self.cursus[etudid]["abandon"] = True + else: + # Est-il réorienté ou a-t-il arrêté (volontairement) sa formation ? + self.cursus[etudid]["abandon"] = arret_de_formation( + identite, cosemestres + ) - def get_semestres_significatifs(self, etudid: int): - """Ensemble des semestres d'un étudiant, qui l'auraient amené à être diplomé - l'année visée (supprime les semestres qui conduisent à une diplomation - postérieure à celle du jury visé) - - Args: - etudid: L'identifiant d'un étudiant - - Returns: - Un dictionnaire ``{fid: FormSemestre(fid)`` dans lequel les semestres - amènent à une diplomation avant l'annee de diplomation du jury - """ - semestres_etudiant = self.cursus[etudid]["formsemestres"] - semestres_significatifs = {} - for fid in semestres_etudiant: - semestre = semestres_etudiant[fid] - if pe_comp.get_annee_diplome_semestre(semestre) <= self.annee_diplome: - semestres_significatifs[fid] = semestre - return semestres_significatifs + # Initialise ses trajectoires/SemX/RCSemX + self.trajectoires[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS} + self.semXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_SEMESTRES} + self.rcsemXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS} def structure_cursus_etudiant(self, etudid: int): """Structure les informations sur les semestres suivis par un @@ -269,9 +286,11 @@ class EtudiantsJuryPE: Cette structuration s'appuie sur les numéros de semestre: pour chaque Si, stocke : le dernier semestre (en date) de numéro i qu'il a suivi (1 ou 0 si pas encore suivi). - Ce semestre influera les interclassement par semestre dans la promo. + Ce semestre influera les interclassements par semestre dans la promo. """ - semestres_significatifs = self.get_semestres_significatifs(etudid) + semestres_significatifs = get_semestres_significatifs( + self.cursus[etudid]["formsemestres"], self.annee_diplome + ) # Tri des semestres par numéro de semestre for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1): @@ -283,12 +302,10 @@ class EtudiantsJuryPE: } self.cursus[etudid][f"S{i}"] = semestres_i - def get_formsemestres_terminaux_aggregat( - self, aggregat: str - ) -> dict[int, FormSemestre]: - """Pour un aggrégat donné, ensemble des formsemestres terminaux possibles pour l'aggrégat - (pour l'aggrégat '3S' incluant S1+S2+S3, a pour semestre terminal S3). - Ces formsemestres traduisent : + def get_formsemestres_finals_des_rcs(self, nom_rcs: str) -> dict[int, FormSemestre]: + """Pour un nom de RCS donné, ensemble des formsemestres finals possibles + pour les RCS. Par ex. un RCS '3S' incluant S1+S2+S3 a pour semestre final un S3. + Les formsemestres finals obtenus traduisent : * les différents parcours des étudiants liés par exemple au choix de modalité (par ex: S1 FI + S2 FI + S3 FI ou S1 FI + S2 FI + S3 UFA), en renvoyant les @@ -299,14 +316,14 @@ class EtudiantsJuryPE: renvoyant les formsemestre_id du S3 (1ère session) et du S3 (2ème session) Args: - aggregat: L'aggrégat + nom_rcs: Le nom du RCS (parmi Sx, xA, xS) Returns: Un dictionnaire ``{fid: FormSemestre(fid)}`` """ formsemestres_terminaux = {} - for trajectoire_aggr in self.trajectoires.values(): - trajectoire = trajectoire_aggr[aggregat] + for trajectoire_aggr in self.cursus.values(): + trajectoire = trajectoire_aggr[nom_rcs] if trajectoire: # Le semestre terminal de l'étudiant de l'aggrégat fid = trajectoire.formsemestre_final.formsemestre_id @@ -345,7 +362,9 @@ class EtudiantsJuryPE: etudiant = self.identites[etudid] cursus = self.cursus[etudid] formsemestres = cursus["formsemestres"] - + parcours = cursus["parcours"] + if not parcours: + parcours = "" if cursus["diplome"]: diplome = cursus["diplome"] else: @@ -359,6 +378,7 @@ class EtudiantsJuryPE: "Prenom": etudiant.prenom, "Civilite": etudiant.civilite_str, "Age": pe_comp.calcul_age(etudiant.date_naissance), + "Parcours": parcours, "Date entree": cursus["entree"], "Date diplome": diplome, "Nb semestres": len(formsemestres), @@ -376,13 +396,35 @@ class EtudiantsJuryPE: return df +def get_semestres_significatifs(formsemestres, annee_diplome): + """Partant d'un ensemble de semestre, renvoie les semestres qui amèneraient les étudiants + à être diplômé à l'année visée, y compris s'ils n'avaient pas redoublé et seraient donc + diplômé plus tard. + + De fait, supprime les semestres qui conduisent à une diplomation postérieure + à celle visée. + + Args: + formsemestres: une liste de formsemestres + annee_diplome: l'année du diplôme visée + + Returns: + Un dictionnaire ``{fid: FormSemestre(fid)}`` dans lequel les semestres + amènent à une diplômation antérieur à celle de la diplômation visée par le jury + """ + # semestres_etudiant = self.cursus[etudid]["formsemestres"] + semestres_significatifs = {} + for fid in formsemestres: + semestre = formsemestres[fid] + if pe_comp.get_annee_diplome_semestre(semestre) <= annee_diplome: + semestres_significatifs[fid] = semestre + return semestres_significatifs + + def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set: """Ensemble d'identifiants des étudiants (identifiés via leur ``etudid``) inscrits à l'un des semestres de la liste de ``semestres``. - Remarque : Les ``cosemestres`` sont généralement obtenus avec - ``sco_formsemestre.do_formsemestre_list()`` - Args: semestres: Un dictionnaire ``{fid: Formsemestre(fid)}`` donnant un ensemble d'identifiant de semestres @@ -430,7 +472,7 @@ def get_annee_diplome(etud: Identite) -> int | None: def get_semestres_apc(identite: Identite) -> list: - """Liste des semestres d'un étudiant qui corresponde à une formation APC. + """Liste des semestres d'un étudiant qui correspondent à une formation APC. Args: identite: L'identité d'un étudiant @@ -446,8 +488,8 @@ def get_semestres_apc(identite: Identite) -> list: return semestres_apc -def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool: - """Détermine si un étudiant a arrêté sa formation. Il peut s'agir : +def arret_de_formation(etud: Identite, cosemestres: dict[int, FormSemestre]) -> bool: + """Détermine si un étudiant a arrêté sa formation (volontairement ou non). Il peut s'agir : * d'une réorientation à l'initiative du jury de semestre ou d'une démission (on pourrait utiliser les code NAR pour réorienté & DEM pour démissionnaire @@ -458,7 +500,8 @@ def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool: Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas dans l'un des "derniers" cosemestres (semestres conduisant à la même année de diplômation) - connu dans Scodoc. + connu dans Scodoc. Par "derniers" cosemestres, est fait le choix d'analyser tous les cosemestres + de rang/semestre_id supérieur (et donc de dates) au dernier semestre dans lequel il a été inscrit. Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc), l'étudiant doit appartenir à une instance des S5 qui conduisent à la diplomation dans @@ -485,7 +528,6 @@ def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool: Est-il réorienté, démissionnaire ou a-t-il arrêté de son propre chef sa formation ? TODO:: A reprendre pour le cas des étudiants à l'étranger - TODO:: A reprendre si BUT avec semestres décalés """ # Les semestres APC de l'étudiant semestres = get_semestres_apc(etud) @@ -493,61 +535,54 @@ def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool: if not semestres_apc: return True - # Son dernier semestre APC en date - dernier_formsemestre = get_dernier_semestre_en_date(semestres_apc) - numero_dernier_formsemestre = dernier_formsemestre.semestre_id + # Le dernier semestre de l'étudiant + dernier_formsemestre = semestres[0] + rang_dernier_semestre = dernier_formsemestre.semestre_id - # Les numéro de semestres possible dans lesquels il pourrait s'incrire - # semestre impair => passage de droit en semestre pair suivant (effet de l'annualisation) - if numero_dernier_formsemestre % 2 == 1: - numeros_possibles = list( - range(numero_dernier_formsemestre + 1, pe_comp.NBRE_SEMESTRES_DIPLOMANT) - ) - # semestre pair => passage en année supérieure ou redoublement - else: # - numeros_possibles = list( - range( - max(numero_dernier_formsemestre - 1, 1), - pe_comp.NBRE_SEMESTRES_DIPLOMANT, - ) + # Les cosemestres de rang supérieur ou égal à celui de formsemestre, triés par rang, + # sous la forme ``{semestre_id: [liste des comestres associé à ce semestre_id]}`` + cosemestres_tries_par_rang = pe_comp.tri_semestres_par_rang(cosemestres) + + cosemestres_superieurs = {} + for rang in cosemestres_tries_par_rang: + if rang > rang_dernier_semestre: + cosemestres_superieurs[rang] = cosemestres_tries_par_rang[rang] + + # Si pas d'autres cosemestres postérieurs + if not cosemestres_superieurs: + return False + + # Pour chaque rang de (co)semestres, y-a-il un dans lequel il est inscrit ? + etat_inscriptions = {rang: False for rang in cosemestres_superieurs} + for rang in etat_inscriptions: + for sem in cosemestres_superieurs[rang]: + etudiants_du_sem = {ins.etudid for ins in sem.inscriptions} + if etud.etudid in etudiants_du_sem: + etat_inscriptions[rang] = True + + # Vérifie qu'il n'y a pas de "trous" dans les rangs des cosemestres + rangs = sorted(etat_inscriptions.keys()) + if list(rangs) != list(range(min(rangs), max(rangs) + 1)): + difference = set(range(min(rangs), max(rangs) + 1)) - set(rangs) + affichage = ",".join([f"S{val}" for val in difference]) + raise ScoValueError( + f"Il manque le(s) semestre(s) {affichage} au cursus de {etud.etat_civil} ({etud.etudid})." ) - # Y-a-t-il des cosemestres dans lesquels il aurait pu s'incrire ? - formsestres_superieurs_possibles = [] - for fid, sem in cosemestres.items(): # Les semestres ayant des inscrits - if ( - fid != dernier_formsemestre.formsemestre_id - and sem.semestre_id in numeros_possibles - and sem.date_debut.year >= dernier_formsemestre.date_debut.year - ): - # date de debut des semestres possibles postérieur au dernier semestre de l'étudiant - # et de niveau plus élevé que le dernier semestre valide de l'étudiant - formsestres_superieurs_possibles.append(fid) + # Est-il inscrit à tous les semestres de rang supérieur ? Si non, est démissionnaire + est_demissionnaire = sum(etat_inscriptions.values()) != len(rangs) + if est_demissionnaire: + non_inscrit_a = [ + rang for rang in etat_inscriptions if not etat_inscriptions[rang] + ] + affichage = ", ".join([f"S{val}" for val in non_inscrit_a]) + pe_affichage.pe_print( + f"--> ⛔ {etud.etat_civil} ({etud.etudid}), non inscrit dans {affichage} amenant à diplômation" + ) + else: + pe_affichage.pe_print(f"--> ✅ {etud.etat_civil} ({etud.etudid})") - if len(formsestres_superieurs_possibles) > 0: - return True - - return False - - -def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre: - """Renvoie le dernier semestre en **date de fin** d'un dictionnaire - de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``. - - Args: - semestres: Un dictionnaire de semestres - - Return: - Le FormSemestre du semestre le plus récent - """ - if semestres: - fid_dernier_semestre = list(semestres.keys())[0] - dernier_semestre: FormSemestre = semestres[fid_dernier_semestre] - for fid in semestres: - if semestres[fid].date_fin > dernier_semestre.date_fin: - dernier_semestre = semestres[fid] - return dernier_semestre - return None + return est_demissionnaire def etapes_du_cursus( @@ -613,6 +648,6 @@ def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str: f"{semestre.date_debut.year}-{semestre.date_fin.year}", ] if avec_fid: - description.append(f"({semestre.formsemestre_id})") + description.append(f"(#{semestre.formsemestre_id})") return " ".join(description) diff --git a/app/pe/pe_interclasstag.py b/app/pe/pe_interclasstag.py deleted file mode 100644 index 895595edd..000000000 --- a/app/pe/pe_interclasstag.py +++ /dev/null @@ -1,160 +0,0 @@ -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -############################################################################## -# Module "Avis de poursuite d'étude" -# conçu et développé par Cléo Baras (IUT de Grenoble) -############################################################################## - -""" -Created on Thu Sep 8 09:36:33 2016 - -@author: barasc -""" - -import pandas as pd -import numpy as np - -from app.pe.pe_tabletags import TableTag, MoyenneTag -from app.pe.pe_etudiant import EtudiantsJuryPE -from app.pe.pe_rcs import RCS, RCSsJuryPE -from app.pe.pe_rcstag import RCSTag - - -class RCSInterclasseTag(TableTag): - """ - Interclasse l'ensemble des étudiants diplômés à une année - donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S') - en reportant : - - * les moyennes obtenues sur la trajectoire qu'il ont suivi pour atteindre - le numéro de semestre de fin de l'aggrégat (indépendamment de son - formsemestre) - * calculant le classement sur les étudiants diplômes - """ - - def __init__( - self, - nom_rcs: str, - etudiants: EtudiantsJuryPE, - rcss_jury_pe: RCSsJuryPE, - rcss_tags: dict[tuple, RCSTag], - ): - TableTag.__init__(self) - - self.nom_rcs = nom_rcs - """Le nom du RCS interclassé""" - - self.nom = self.get_repr() - - """Les étudiants diplômés et leurs rcss""" # TODO - self.diplomes_ids = etudiants.etudiants_diplomes - self.etudiants_diplomes = {etudid for etudid in self.diplomes_ids} - # pour les exports sous forme de dataFrame - self.etudiants = { - etudid: etudiants.identites[etudid].etat_civil - for etudid in self.diplomes_ids - } - - # Les trajectoires (et leur version tagguées), en ne gardant que - # celles associées à l'aggrégat - self.rcss: dict[int, RCS] = {} - """Ensemble des trajectoires associées à l'aggrégat""" - for trajectoire_id in rcss_jury_pe.rcss: - trajectoire = rcss_jury_pe.rcss[trajectoire_id] - if trajectoire_id[0] == nom_rcs: - self.rcss[trajectoire_id] = trajectoire - - self.trajectoires_taggues: dict[int, RCS] = {} - """Ensemble des trajectoires tagguées associées à l'aggrégat""" - for trajectoire_id in self.rcss: - self.trajectoires_taggues[trajectoire_id] = rcss_tags[trajectoire_id] - - # Les trajectoires suivies par les étudiants du jury, en ne gardant que - # celles associées aux diplomés - self.suivi: dict[int, RCS] = {} - """Association entre chaque étudiant et la trajectoire tagguée à prendre en - compte pour l'aggrégat""" - for etudid in self.diplomes_ids: - self.suivi[etudid] = rcss_jury_pe.suivi[etudid][nom_rcs] - - self.tags_sorted = self.do_taglist() - """Liste des tags (triés par ordre alphabétique)""" - - # Construit la matrice de notes - self.notes = self.compute_notes_matrice() - """Matrice des notes de l'aggrégat""" - - # Synthétise les moyennes/classements par tag - self.moyennes_tags: dict[str, MoyenneTag] = {} - for tag in self.tags_sorted: - moy_gen_tag = self.notes[tag] - self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag) - - # Est significatif ? (aka a-t-il des tags et des notes) - self.significatif = len(self.tags_sorted) > 0 - - def get_repr(self) -> str: - """Une représentation textuelle""" - return f"Aggrégat {self.nom_rcs}" - - def do_taglist(self): - """Synthétise les tags à partir des trajectoires_tagguées - - Returns: - Une liste de tags triés par ordre alphabétique - """ - tags = [] - for trajectoire in self.trajectoires_taggues.values(): - tags.extend(trajectoire.tags_sorted) - return sorted(set(tags)) - - def compute_notes_matrice(self): - """Construit la matrice de notes (etudid x tags) - retraçant les moyennes obtenues par les étudiants dans les semestres associés à - l'aggrégat (une trajectoire ayant pour numéro de semestre final, celui de l'aggrégat). - """ - # nb_tags = len(self.tags_sorted) unused ? - # nb_etudiants = len(self.diplomes_ids) - - # Index de la matrice (etudids -> dim 0, tags -> dim 1) - etudids = list(self.diplomes_ids) - tags = self.tags_sorted - - # Partant d'un dataframe vierge - df = pd.DataFrame(np.nan, index=etudids, columns=tags) - - for trajectoire in self.trajectoires_taggues.values(): - # Charge les moyennes par tag de la trajectoire tagguée - notes = trajectoire.notes - # Etudiants/Tags communs entre la trajectoire_tagguée et les données interclassées - etudids_communs = df.index.intersection(notes.index) - tags_communs = df.columns.intersection(notes.columns) - - # Injecte les notes par tag - df.loc[etudids_communs, tags_communs] = notes.loc[ - etudids_communs, tags_communs - ] - - return df diff --git a/app/pe/pe_jury.py b/app/pe/pe_jury.py index a6c15b8c5..3881615b3 100644 --- a/app/pe/pe_jury.py +++ b/app/pe/pe_jury.py @@ -49,14 +49,23 @@ from zipfile import ZipFile import numpy as np import pandas as pd +import jinja2 + +from app.pe.rcss import pe_rcs +from app.pe.moys import pe_sxtag -from app.pe.pe_affichage import NOM_STAT_PROMO, SANS_NOTE, NOM_STAT_GROUPE import app.pe.pe_affichage as pe_affichage -from app.pe.pe_etudiant import * # TODO A éviter -> pe_etudiant. -from app.pe.pe_rcs import * # TODO A éviter -from app.pe.pe_rcstag import RCSTag -from app.pe.pe_semtag import SemestreTag -from app.pe.pe_interclasstag import RCSInterclasseTag +import app.pe.pe_etudiant as pe_etudiant +from app.pe.moys import ( + pe_tabletags, + pe_ressemtag, + pe_sxtag, + pe_rcstag, + pe_interclasstag, + pe_moytag, +) +import app.pe.pe_rcss_jury as pe_rcss_jury +from app.scodoc.sco_utils import * class JuryPE(object): @@ -66,41 +75,83 @@ class JuryPE(object): 1. l'année d'obtention du DUT, 2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés. + Les options sont : + `` + options = { + "cohorte_restreinte": False, + "moyennes_tags": True, + "moyennes_ue_res_sae": True, + "moyennes_ues_rcues": True, + "min_max_moy": False, + "synthese_individuelle_etud": False, + } + Args: diplome : l'année d'obtention du diplome BUT et du jury de PE (généralement février XXXX) """ - def __init__(self, diplome): + def __init__(self, diplome: int, formsemestre_id_base, options=None): pe_affichage.pe_start_log() self.diplome = diplome "L'année du diplome" + self.formsemestre_id_base = formsemestre_id_base + """L'identifiant du formsemestre ayant servi à lancer le jury""" + self.nom_export_zip = f"Jury_PE_{self.diplome}" "Nom du zip où ranger les fichiers générés" + # Les options + self.options = options + """Options de configuration (cf. pe_sem_recap)""" + pe_affichage.pe_print( - f"Données de poursuite d'étude générées le {time.strftime('%d/%m/%Y à %H:%M')}\n" + f"Données de poursuite d'étude générées le {time.strftime('%d/%m/%Y à %H:%M')}\n", + info=True, ) + + pe_affichage.pe_print("Options", info=True) + for cle, val in self.options.items(): + pe_affichage.pe_print(f" > {cle} -> {val}", info=True) + # Chargement des étudiants à prendre en compte dans le jury pe_affichage.pe_print( - f"""*** Recherche et chargement des étudiants diplômés en { - self.diplome}""" + f"""***********************************************************""" ) - self.etudiants = EtudiantsJuryPE(self.diplome) # Les infos sur les étudiants + pe_affichage.pe_print( + f"""*** Recherche des étudiants diplômés 🎓 en {self.diplome}""", info=True + ) + pe_affichage.pe_print( + f"""***********************************************************""" + ) + + # Les infos sur les étudiants + self.etudiants = pe_etudiant.EtudiantsJuryPE(self.diplome) + """Les informations sur les étudiants du jury PE""" self.etudiants.find_etudiants() self.diplomes_ids = self.etudiants.diplomes_ids + self.rcss_jury = pe_rcss_jury.RCSsJuryPE(self.diplome, self.etudiants) + """Les informations sur les regroupements de semestres""" + self.zipdata = io.BytesIO() with ZipFile(self.zipdata, "w") as zipfile: if not self.diplomes_ids: - pe_affichage.pe_print("*** Aucun étudiant diplômé") + pe_affichage.pe_print("*** Aucun étudiant diplômé", info=True) else: - self._gen_xls_diplomes(zipfile) - self._gen_xls_semestre_taggues(zipfile) - self._gen_xls_rcss_tags(zipfile) - self._gen_xls_interclassements_rcss(zipfile) - self._gen_xls_synthese_jury_par_tag(zipfile) - self._gen_xls_synthese_par_etudiant(zipfile) + try: + self._gen_xls_diplomes(zipfile) + self._gen_xls_ressembuttags(zipfile) + self._gen_trajectoires() + self._gen_semXs() + self._gen_xls_sxtags(zipfile) + self._gen_rcsemxs() + self._gen_xls_rcstags(zipfile) + self._gen_xls_interclasstags(zipfile) + self._gen_xls_synthese_jury_par_tag(zipfile) + self._gen_html_synthese_par_etudiant(zipfile) + except Exception as e: + raise e # et le log self._add_log_to_zip(zipfile) @@ -131,132 +182,430 @@ class JuryPE(object): path="details", ) - def _gen_xls_semestre_taggues(self, zipfile: ZipFile): - "Génère les semestres taggués (avec le calcul des moyennes) pour le jury PE" - pe_affichage.pe_print("*** Génère les semestres taggués") - self.sems_tags = compute_semestres_tag(self.etudiants) + def _gen_xls_ressembuttags(self, zipfile: ZipFile): + """Calcule les moyennes par tag des résultats des Semestres BUT""" + pe_affichage.pe_print( + f"""*************************************************************************""" + ) + pe_affichage.pe_print( + f"""*** Génère les ResSemBUTTag (ResSemestreBUT taggués)""", info=True + ) + pe_affichage.pe_print( + f"""*************************************************************************""" + ) + + # Tous les formsestres des étudiants + formsemestres = get_formsemestres_etudiants(self.etudiants) + pe_affichage.pe_print( + f"1) Génère les {len(formsemestres)} ResSemBUTTag", info=True + ) + + self.ressembuttags = {} + for frmsem_id, formsemestre in formsemestres.items(): + # Crée le semestre_tag et exécute les calculs de moyennes + ressembuttag = pe_ressemtag.ResSemBUTTag(formsemestre, options=self.options) + self.ressembuttags[frmsem_id] = ressembuttag + # Ajoute les étudiants découverts dans les ressembuttags aux données des étudiants + # nbre_etudiants_ajoutes = self.etudiants.add_etudiants( + # ressembuttag.etudids_sorted + # ) + # if nbre_etudiants_ajoutes: + # pe_affichage.pe_print( + # f"--> Ajout de {nbre_etudiants_ajoutes} étudiants aux données du jury" + # ) # Intègre le bilan des semestres taggués au zip final + pe_affichage.pe_print(f"2) Bilan", info=True) output = io.BytesIO() with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated output, engine="openpyxl" ) as writer: - for formsemestretag in self.sems_tags.values(): - onglet = formsemestretag.nom - df = formsemestretag.df_moyennes_et_classements() + onglets = [] + for res_sem_tag in self.ressembuttags.values(): + onglet = res_sem_tag.get_repr(verbose=True) + onglet = onglet.replace("Semestre ", "S") + onglets += ["📊" + onglet] + df = res_sem_tag.to_df() + # Conversion colonnes en multiindex + df = convert_colonnes_to_multiindex(df) # écriture dans l'onglet df.to_excel(writer, onglet, index=True, header=True) + pe_affichage.pe_print( + f"--> Export excel de {', '.join(onglets)}", info=True + ) output.seek(0) self.add_file_to_zip( zipfile, - f"semestres_taggues_{self.diplome}.xlsx", + f"ResSemBUTTags_{self.diplome}.xlsx", output.read(), path="details", ) - def _gen_xls_rcss_tags(self, zipfile: ZipFile): - """Génère les RCS (combinaisons de semestres suivis - par un étudiant) + def _gen_trajectoires(self): + """Génère l'ensemble des trajectoires (RCS), qui traduisent les différents + chemins au sein des (form)semestres pour atteindre la cible d'un + RCS (par ex: 'S2' ou '3S'). """ pe_affichage.pe_print( - "*** Génère les trajectoires (différentes combinaisons de semestres) des étudiants" + "***************************************************************************" ) - self.rcss = RCSsJuryPE(self.diplome) - self.rcss.cree_rcss(self.etudiants) - - # Génère les moyennes par tags des trajectoires - pe_affichage.pe_print("*** Calcule les moyennes par tag des RCS possibles") - self.rcss_tags = compute_trajectoires_tag( - self.rcss, self.etudiants, self.sems_tags + pe_affichage.pe_print( + "*** Génère les trajectoires (≠tes combinaisons de semestres) des étudiants", + info=True, + ) + pe_affichage.pe_print( + "***************************************************************************" ) - # Intègre le bilan des trajectoires tagguées au zip final + self.rcss_jury.cree_trajectoires() + pe_affichage.aff_trajectoires_suivies_par_etudiants(self.etudiants) + + def _gen_semXs(self): + """Génère les SemXs (trajectoires/combinaisons de semestre de même rang x) + qui traduisent les différents chemins des étudiants pour valider un semestre Sx. + """ + pe_affichage.pe_print( + "***************************************************************************" + ) + pe_affichage.pe_print( + "*** Génère les SemXs (RCS de même Sx donnant lieu à validation du semestre)", + info=True, + ) + pe_affichage.pe_print( + "***************************************************************************" + ) + + # Génère les regroupements de semestres de type Sx + + self.rcss_jury.cree_semxs() + pe_affichage.aff_semXs_suivis_par_etudiants(self.etudiants) + + def _gen_xls_sxtags(self, zipfile: ZipFile): + """Génère les semestres taggués en s'appuyant sur les RCF de type Sx (pour + identifier les redoublements impactant les semestres taggués). + """ + # Génère les moyennes des RCS de type Sx + pe_affichage.pe_print( + "***************************************************************************" + ) + pe_affichage.pe_print( + "*** Calcule les moyennes des SxTag (moyennes d'un RCS de type Sx)", + info=True, + ) + pe_affichage.pe_print( + "***************************************************************************" + ) + + # Les SxTag (moyenne de Sx par UE) + pe_affichage.pe_print("1) Calcul des moyennes", info=True) + self.sxtags = {} + for rcf_id, rcf in self.rcss_jury.semXs.items(): + # SxTag traduisant le RCF + sxtag_id = rcf_id + self.sxtags[sxtag_id] = pe_sxtag.SxTag(sxtag_id, rcf, self.ressembuttags) + + # Intègre le bilan des semestres taggués au zip final + pe_affichage.pe_print("2) Bilan", info=True) output = io.BytesIO() with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated output, engine="openpyxl" ) as writer: - for rcs_tag in self.rcss_tags.values(): - onglet = rcs_tag.get_repr() - df = rcs_tag.df_moyennes_et_classements() - # écriture dans l'onglet - df.to_excel(writer, onglet, index=True, header=True) + onglets = [] + for sxtag in self.sxtags.values(): + if sxtag.is_significatif(): + onglet = sxtag.get_repr(verbose=False) + onglets += ["📊" + onglet] + df = sxtag.to_df() + # Conversion colonnes en multiindex + df = convert_colonnes_to_multiindex(df) + + # écriture dans l'onglet + df.to_excel(writer, onglet, index=True, header=True) + pe_affichage.pe_print( + f"--> Export excel de {', '.join(onglets)}", info=True + ) + + output.seek(0) + if onglets: + self.add_file_to_zip( + zipfile, + f"semestres_taggues_{self.diplome}.xlsx", + output.read(), + path="details", + ) + + def _gen_rcsemxs(self): + """Génère les regroupements cohérents de RCFs qu'ont suivi chaque étudiant""" + + pe_affichage.pe_print( + """******************************************************************************""" + ) + pe_affichage.pe_print( + """*** Génère les RCSemX (regroupements cohérents de données extraites des SemX)\n""" + """*** amenant du S1 à un semestre final""", + info=True, + ) + pe_affichage.pe_print( + """******************************************************************************""" + ) + self.rcss_jury.cree_rcsemxs(options=self.options) + if "moyennes_ues_rcues" in self.options and self.options["moyennes_ues_rcues"]: + pe_affichage.aff_rcsemxs_suivis_par_etudiants(self.etudiants) + + def _gen_xls_rcstags(self, zipfile: ZipFile): + """Génère les RCS taggués traduisant les moyennes (orientées compétences) + de regroupements de semestre de type Sx, xA ou xS. + + Stocke le résultat dans self.rccs_tag, un dictionnaire de + la forme ``{nom_aggregat: {fid_terminal: RCSTag(fid_terminal)} }`` + + Pour rappel : Chaque RCS est identifié par un nom d'aggrégat et par un formsemestre terminal. + + Par exemple : + + * combinaisons '3S' : S1+S2+S3 en prenant en compte tous les S3 qu'ont fréquenté les + étudiants du jury PE. Ces S3 marquent les formsemestre terminal de chaque combinaison. + + Args: + etudiants: Les données des étudiants + semestres_tag: Les semestres tag (pour lesquels des moyennes par tag ont été calculés) + """ + + # Génère les moyennes des RCS de type Sx + pe_affichage.pe_print( + """****************************************************""" + ) + pe_affichage.pe_print( + """*** Génère les moyennes associées aux RCSemX""", info=True + ) + pe_affichage.pe_print( + """****************************************************""" + ) + + pe_affichage.pe_print("1) Calcul des moyennes des RCSTag", info=True) + if not self.rcss_jury.rcsemxs: + if ( + "moyennes_ues_rcues" in self.options + and not self.options["moyennes_ues_rcues"] + ): + pe_affichage.pe_print(" -> Pas de RCSemX à calculer (cf. options)") + else: + pe_affichage.pe_print( + " -> Pas de RCSemX à calculer (alors qu'aucune option ne les limite) => problème" + ) + self.rcsstags = {} + return + + # Calcul des RCSTags sur la base des RCSemX + self.rcsstags = {} + for rcs_id, rcsemx in self.rcss_jury.rcsemxs.items(): + self.rcsstags[rcs_id] = pe_rcstag.RCSemXTag( + rcsemx, self.sxtags, self.rcss_jury.semXs_suivis + ) + + # Intègre le bilan des trajectoires tagguées au zip final + pe_affichage.pe_print("2) Bilan", info=True) + output = io.BytesIO() + with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated + output, engine="openpyxl" + ) as writer: + onglets = [] + for rcs_tag in self.rcsstags.values(): + if rcs_tag.is_significatif(): + onglet = rcs_tag.get_repr(verbose=False) + onglets += ["📊" + onglet] + + df = rcs_tag.to_df() + # Conversion colonnes en multiindex + df = convert_colonnes_to_multiindex(df) + onglets += ["📊" + onglet] + # écriture dans l'onglet + df.to_excel(writer, onglet, index=True, header=True) + pe_affichage.pe_print( + f"--> Export excel de {', '.join(onglets)}", info=True + ) output.seek(0) - self.add_file_to_zip( - zipfile, - f"RCS_taggues_{self.diplome}.xlsx", - output.read(), - path="details", + if onglets: + self.add_file_to_zip( + zipfile, + f"RCRCFs_{self.diplome}.xlsx", + output.read(), + path="details", + ) + + def _gen_xls_interclasstags(self, zipfile: ZipFile): + """Génère les interclassements sur la promo de diplômés + par (nom d') aggrégat + en distinguant les interclassements par accronymes d'UEs (sur les SxTag) + et ceux par compétences (sur les RCSTag). + """ + pe_affichage.pe_print( + """******************************************************************""" + ) + pe_affichage.pe_print( + """*** Génère les interclassements sur chaque type de RCS/agrégat""" + ) + pe_affichage.pe_print( + """******************************************************************""" ) - def _gen_xls_interclassements_rcss(self, zipfile: ZipFile): - """Intègre le bilan des RCS (interclassé par promo) au zip""" - # Génère les interclassements (par promo et) par (nom d') aggrégat - pe_affichage.pe_print("*** Génère les interclassements par aggrégat") - self.interclassements_taggues = compute_interclassements( - self.etudiants, self.rcss, self.rcss_tags - ) + if ( + "moyennes_ues_rcues" not in self.options + or self.options["moyennes_ues_rcues"] + ): + self.interclasstags = { + pe_moytag.CODE_MOY_UE: {}, + pe_moytag.CODE_MOY_COMPETENCES: {}, + } + else: + self.interclasstags = {pe_moytag.CODE_MOY_UE: {}} + + etudiants_diplomes = self.etudiants.etudiants_diplomes + + # Les interclassements par UE (toujours présents par défaut) + for Sx in pe_rcs.TOUS_LES_SEMESTRES: + interclass = pe_interclasstag.InterClassTag( + Sx, + pe_moytag.CODE_MOY_UE, + etudiants_diplomes, + self.rcss_jury.semXs, + self.sxtags, + self.rcss_jury.semXs_suivis, + ) + self.interclasstags[pe_moytag.CODE_MOY_UE][Sx] = interclass + + # Les interclassements par compétences + if ( + "moyennes_ues_rcues" not in self.options + or self.options["moyennes_ues_rcues"] + ): + for nom_rcs in pe_rcs.TOUS_LES_RCS: + interclass = pe_interclasstag.InterClassTag( + nom_rcs, + pe_moytag.CODE_MOY_COMPETENCES, + etudiants_diplomes, + self.rcss_jury.rcsemxs, + self.rcsstags, + self.rcss_jury.rcsemxs_suivis, + ) + self.interclasstags[pe_moytag.CODE_MOY_COMPETENCES][ + nom_rcs + ] = interclass # Intègre le bilan des aggrégats (interclassé par promo) au zip final output = io.BytesIO() with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated output, engine="openpyxl" ) as writer: - for interclass_tag in self.interclassements_taggues.values(): - if interclass_tag.significatif: # Avec des notes - onglet = interclass_tag.get_repr() - df = interclass_tag.df_moyennes_et_classements() - # écriture dans l'onglet - df.to_excel(writer, onglet, index=True, header=True) + onglets = [] + for ( + type_interclass + ) in self.interclasstags: # Pour les types d'interclassements prévus + interclasstag = self.interclasstags[type_interclass] + for nom_rcs, interclass in interclasstag.items(): + if interclass.is_significatif(): + onglet = interclass.get_repr() + onglets += ["📊" + onglet] + df = interclass.to_df(cohorte="Promo") + # Conversion colonnes en multiindex + df = convert_colonnes_to_multiindex(df) + onglets += [onglet] + # écriture dans l'onglet + df.to_excel(writer, onglet, index=True, header=True) + pe_affichage.pe_print(f"=> Export excel de {', '.join(onglets)}", info=True) + output.seek(0) - self.add_file_to_zip( - zipfile, - f"interclassements_taggues_{self.diplome}.xlsx", - output.read(), - path="details", - ) + if onglets: + self.add_file_to_zip( + zipfile, + f"InterClassTags_{self.diplome}.xlsx", + output.read(), + path="details", + ) def _gen_xls_synthese_jury_par_tag(self, zipfile: ZipFile): """Synthèse des éléments du jury PE tag par tag""" - # Synthèse des éléments du jury PE - self.synthese = self.synthetise_jury_par_tags() + pe_affichage.pe_print( + "**************************************************************************************" + ) + pe_affichage.pe_print( + "*** Synthèse finale des moyennes par tag et par type de moyennes (UEs ou Compétences)", + info=True, + ) + pe_affichage.pe_print( + "**************************************************************************************" + ) + + self.synthese = {} + pe_affichage.pe_print(" -> Synthèse des données administratives", info=True) + self.synthese["administratif"] = self.etudiants.df_administratif( + self.diplomes_ids + ) + + tags = self._do_tags_list(self.interclasstags) + for tag in tags: + for type_moy in self.interclasstags: + self.synthese[(tag, type_moy)] = self.df_tag_type(tag, type_moy) # Export des données => mode 1 seule feuille -> supprimé - pe_affichage.pe_print("*** Export du jury de synthese par tags") + pe_affichage.pe_print("*** Export du jury de synthese par tags", info=True) output = io.BytesIO() with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated output, engine="openpyxl" ) as writer: + onglets = [] for onglet, df in self.synthese.items(): + # Conversion colonnes en multiindex + df_final = df.copy() + if ( + "publipostage" not in self.options + or not self.options["publipostage"] + ): + df_final = convert_colonnes_to_multiindex(df_final) + # Nom de l'onglet + if isinstance(onglet, tuple): + (repr, type_moy) = onglet + nom_onglet = onglet[0][: 31 - 7] + if type_moy == pe_moytag.CODE_MOY_COMPETENCES: + nom_onglet = nom_onglet + " (Comp)" + else: + nom_onglet = nom_onglet + " (UEs)" + else: + nom_onglet = onglet + onglets += [nom_onglet] # écriture dans l'onglet: - df.to_excel(writer, onglet, index=True, header=True) + df_final = df_final.replace("nan", "") + df_final.to_excel(writer, nom_onglet, index=True, header=True) + pe_affichage.pe_print(f"=> Export excel de {', '.join(onglets)}", info=True) output.seek(0) - self.add_file_to_zip( - zipfile, f"synthese_jury_{self.diplome}_par_tag.xlsx", output.read() - ) + if onglets: + self.add_file_to_zip( + zipfile, f"synthese_jury_{self.diplome}.xlsx", output.read() + ) - def _gen_xls_synthese_par_etudiant(self, zipfile: ZipFile): + def _gen_html_synthese_par_etudiant(self, zipfile: ZipFile): """Synthèse des éléments du jury PE, étudiant par étudiant""" # Synthèse des éléments du jury PE - synthese = self.synthetise_jury_par_etudiants() + pe_affichage.pe_print("**************************************************") + pe_affichage.pe_print("*** Synthèse finale étudiant par étudiant", info=True) + pe_affichage.pe_print("**************************************************") - # Export des données => mode 1 seule feuille -> supprimé - pe_affichage.pe_print("*** Export du jury de synthese par étudiants") - output = io.BytesIO() - with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated - output, engine="openpyxl" - ) as writer: - for onglet, df in synthese.items(): - # écriture dans l'onglet: - df.to_excel(writer, onglet, index=True, header=True) - output.seek(0) - - self.add_file_to_zip( - zipfile, f"synthese_jury_{self.diplome}_par_etudiant.xlsx", output.read() - ) + if ( + "moyennes_ues_rcues" not in self.options + or self.options["moyennes_ues_rcues"] + ): + etudids = list(self.diplomes_ids) + for etudid in etudids: + nom, prenom, html = self.synthetise_jury_etudiant(etudid) + self.add_file_to_zip( + zipfile, f"{nom}_{prenom}.html", html, path="etudiants" + ) + else: + pe_affichage.pe_print(" > Pas de synthèse étudiant/étudiant possible/prévu") def _add_log_to_zip(self, zipfile): """Add a text file with the log messages""" @@ -284,245 +633,181 @@ class JuryPE(object): self.zipdata.seek(0) return self.zipdata - def do_tags_list(self, interclassements: dict[str, RCSInterclasseTag]): + def _do_tags_list(self, interclassements: dict[str, dict]): """La liste des tags extraites des interclassements""" tags = [] - for aggregat in interclassements: - interclass = interclassements[aggregat] - if interclass.tags_sorted: - tags.extend(interclass.tags_sorted) - tags = sorted(set(tags)) - return tags + # Pour chaque type d'interclassements + for type in interclassements: + interclassement = interclassements[type] + for aggregat in interclassement: + interclass = interclassement[aggregat] + if interclass.tags_sorted: + tags.extend(interclass.tags_sorted) + tags = sorted(set(tags)) + return tags # **************************************************************************************************************** # # Méthodes pour la synthèse du juryPE # ***************************************************************************************************************** - def synthetise_jury_par_tags(self) -> dict[pd.DataFrame]: - """Synthétise tous les résultats du jury PE dans des dataframes, - dont les onglets sont les tags""" - - pe_affichage.pe_print("*** Synthèse finale des moyennes par tag***") - - synthese = {} - pe_affichage.pe_print(" -> Synthèse des données administratives") - synthese["administratif"] = self.etudiants.df_administratif(self.diplomes_ids) - - tags = self.do_tags_list(self.interclassements_taggues) - for tag in tags: - pe_affichage.pe_print(f" -> Synthèse du tag {tag}") - synthese[tag] = self.df_tag(tag) - return synthese - - def df_tag(self, tag): - """Génère le DataFrame synthétisant les moyennes/classements (groupe, - interclassement promo) pour tous les aggrégats prévus, + def df_tag_type(self, tag, type_moy): + """Génère le DataFrame synthétisant les moyennes/classements (groupe + + interclassement promo) pour tous les aggrégats prévus, en fonction + du type (UEs ou Compétences) de données souhaitées, tels que fourni dans l'excel final. + Si type=UEs => tous les sxtag du tag + Si type=Compétences => tous les rcstag du tag + Args: tag: Un des tags (a minima `but`) + type_moy: Un type de moyenne Returns: """ - etudids = list(self.diplomes_ids) - # Les données des étudiants - donnees_etudiants = {} - for etudid in etudids: - etudiant = self.etudiants.identites[etudid] - donnees_etudiants[etudid] = { - ("Identité", "", "Civilite"): etudiant.civilite_str, - ("Identité", "", "Nom"): etudiant.nom, - ("Identité", "", "Prenom"): etudiant.prenom, - } - df_synthese = pd.DataFrame.from_dict(donnees_etudiants, orient="index") + etuds = [etud for etudid, etud in self.etudiants.etudiants_diplomes.items()] + df = pe_tabletags.df_administratif(etuds, aggregat="Administratif", cohorte="") - # Ajout des aggrégats - for aggregat in TOUS_LES_RCS: - descr = TYPES_RCS[aggregat]["descr"] + if type_moy == pe_moytag.CODE_MOY_UE: + aggregats = pe_rcs.TOUS_LES_SEMESTRES + else: + aggregats = pe_rcs.TOUS_LES_RCS - # Les trajectoires (tagguées) suivies par les étudiants pour l'aggrégat et le tag - # considéré - trajectoires_tagguees = [] - for etudid in etudids: - trajectoire = self.rcss.suivi[etudid][aggregat] - if trajectoire: - tid = trajectoire.rcs_id - trajectoire_tagguee = self.rcss_tags[tid] - if ( - tag in trajectoire_tagguee.moyennes_tags - and trajectoire_tagguee not in trajectoires_tagguees - ): - trajectoires_tagguees.append(trajectoire_tagguee) + aff_aggregat = [] + for aggregat in aggregats: + # Descr de l'aggrégat + descr = pe_rcs.TYPES_RCS[aggregat]["descr"] - # Combien de notes vont être injectées ? - nbre_notes_injectees = 0 - for traj in trajectoires_tagguees: - moy_traj = traj.moyennes_tags[tag] - inscrits_traj = moy_traj.inscrits_ids - etudids_communs = set(etudids) & set(inscrits_traj) - nbre_notes_injectees += len(etudids_communs) + # L'interclassement associé + interclass = self.interclasstags[type_moy][aggregat] - # Si l'aggrégat est significatif (aka il y a des notes) - if nbre_notes_injectees > 0: - # Ajout des données classements & statistiques - nom_stat_promo = f"{NOM_STAT_PROMO} {self.diplome}" - donnees = pd.DataFrame( - index=etudids, - columns=[ - [descr] * (1 + 4 * 2), - [""] + [NOM_STAT_GROUPE] * 4 + [nom_stat_promo] * 4, - ["note"] + ["class.", "min", "moy", "max"] * 2, - ], + if interclass.is_significatif(): + # Le dataframe du classement sur le groupe + df_groupe = interclass.compute_df_synthese_moyennes_tag( + tag, aggregat=aggregat, type_colonnes=False, options=self.options + ) + if not df_groupe.empty: + aff_aggregat += [aggregat] + df = df.join(df_groupe) + + # Le dataframe du classement sur la promo + df_promo = interclass.to_df( + administratif=False, + aggregat=aggregat, + tags_cibles=[tag], + cohorte="Promo", + options=self.options, ) - for traj in trajectoires_tagguees: - # Les données des trajectoires_tagguees - moy_traj = traj.moyennes_tags[tag] + if not df_promo.empty: + aff_aggregat += [aggregat] + df = df.join(df_promo) - # Les étudiants communs entre tableur de synthèse et trajectoires - inscrits_traj = moy_traj.inscrits_ids - etudids_communs = list(set(etudids) & set(inscrits_traj)) + if aff_aggregat: + pe_affichage.pe_print( + f" -> Synthèse de 👜{tag} par {type_moy} avec {', '.join(aff_aggregat)}" + ) + else: + pe_affichage.pe_print(f" -> Synthèse du tag {tag} par {type_moy} : ") - # Les notes - champ = (descr, "", "note") - notes_traj = moy_traj.get_notes() - donnees.loc[etudids_communs, champ] = notes_traj.loc[ - etudids_communs - ] - - # Les rangs - champ = (descr, NOM_STAT_GROUPE, "class.") - rangs = moy_traj.get_rangs_inscrits() - donnees.loc[etudids_communs, champ] = rangs.loc[etudids_communs] - - # Les mins - champ = (descr, NOM_STAT_GROUPE, "min") - mins = moy_traj.get_min() - donnees.loc[etudids_communs, champ] = mins.loc[etudids_communs] - - # Les max - champ = (descr, NOM_STAT_GROUPE, "max") - maxs = moy_traj.get_max() - donnees.loc[etudids_communs, champ] = maxs.loc[etudids_communs] - - # Les moys - champ = (descr, NOM_STAT_GROUPE, "moy") - moys = moy_traj.get_moy() - donnees.loc[etudids_communs, champ] = moys.loc[etudids_communs] - - # Ajoute les données d'interclassement - interclass = self.interclassements_taggues[aggregat] - moy_interclass = interclass.moyennes_tags[tag] - - # Les étudiants communs entre tableur de synthèse et l'interclassement - inscrits_interclass = moy_interclass.inscrits_ids - etudids_communs = list(set(etudids) & set(inscrits_interclass)) - - # Les classements d'interclassement - champ = (descr, nom_stat_promo, "class.") - rangs = moy_interclass.get_rangs_inscrits() - donnees.loc[etudids_communs, champ] = rangs.loc[etudids_communs] - - # Les mins - champ = (descr, nom_stat_promo, "min") - mins = moy_interclass.get_min() - donnees.loc[etudids_communs, champ] = mins.loc[etudids_communs] - - # Les max - champ = (descr, nom_stat_promo, "max") - maxs = moy_interclass.get_max() - donnees.loc[etudids_communs, champ] = maxs.loc[etudids_communs] - - # Les moys - champ = (descr, nom_stat_promo, "moy") - moys = moy_interclass.get_moy() - donnees.loc[etudids_communs, champ] = moys.loc[etudids_communs] - - df_synthese = df_synthese.join(donnees) - # Fin de l'aggrégat - - # Tri par nom/prénom - df_synthese.sort_values( - by=[("Identité", "", "Nom"), ("Identité", "", "Prenom")], inplace=True - ) - return df_synthese - - def synthetise_jury_par_etudiants(self) -> dict[pd.DataFrame]: - """Synthétise tous les résultats du jury PE dans des dataframes, - dont les onglets sont les étudiants""" - pe_affichage.pe_print("*** Synthèse finale des moyennes par étudiants***") - - synthese = {} - pe_affichage.pe_print(" -> Synthèse des données administratives") - synthese["administratif"] = self.etudiants.df_administratif(self.diplomes_ids) - - etudids = list(self.diplomes_ids) - - for etudid in etudids: - etudiant = self.etudiants.identites[etudid] - nom = etudiant.nom - prenom = etudiant.prenom[0] # initial du prénom - - onglet = f"{nom} {prenom}. ({etudid})" - if len(onglet) > 32: # limite sur la taille des onglets - fin_onglet = f"{prenom}. ({etudid})" - onglet = f"{nom[:32-len(fin_onglet)-2]}." + fin_onglet - - pe_affichage.pe_print(f" -> Synthèse de l'étudiant {etudid}") - synthese[onglet] = self.df_synthese_etudiant(etudid) - return synthese - - def df_synthese_etudiant(self, etudid: int) -> pd.DataFrame: - """Créé un DataFrame pour un étudiant donné par son etudid, retraçant - toutes ses moyennes aux différents tag et aggrégats""" - tags = self.do_tags_list(self.interclassements_taggues) - - donnees = {} - - for tag in tags: - # Une ligne pour le tag - donnees[tag] = {("", "", "tag"): tag} - - for aggregat in TOUS_LES_RCS: - # Le dictionnaire par défaut des moyennes - donnees[tag] |= get_defaut_dict_synthese_aggregat( - aggregat, self.diplome - ) - - # La trajectoire de l'étudiant sur l'aggrégat - trajectoire = self.rcss.suivi[etudid][aggregat] - if trajectoire: - trajectoire_tagguee = self.rcss_tags[trajectoire.rcs_id] - if tag in trajectoire_tagguee.moyennes_tags: - # L'interclassement - interclass = self.interclassements_taggues[aggregat] - - # Injection des données dans un dictionnaire - donnees[tag] |= get_dict_synthese_aggregat( - aggregat, - trajectoire_tagguee, - interclass, - etudid, - tag, - self.diplome, - ) - - # Fin de l'aggrégat - # Construction du dataFrame - df = pd.DataFrame.from_dict(donnees, orient="index") - - # Tri par nom/prénom - df.sort_values(by=[("", "", "tag")], inplace=True) return df + # Fin de l'aggrégat + + def synthetise_jury_etudiant(self, etudid) -> (str, str, str): + """Synthétise les résultats d'un étudiant dans un + fichier html à son nom en s'appuyant sur la synthese final + + Returns: + Un tuple nom, prenom, html + """ + etudiant = self.etudiants.identites[etudid] + nom = etudiant.nom + prenom = etudiant.prenom # initial du prénom + parcours = self.etudiants.cursus[etudid]["parcours"] + if not parcours: + parcours = "" + + # Accès au template + environnement = jinja2.Environment( + loader=jinja2.FileSystemLoader("app/templates/") + ) + template = environnement.get_template("pe/pe_view_resultats_etudiant.j2") + + # Colonnes des tableaux htmls => competences + competences = [] + for aggregat in pe_rcs.TOUS_LES_RCS: + # L'interclassement associé + interclass = self.interclasstags[pe_moytag.CODE_MOY_COMPETENCES][aggregat] + competences.extend(interclass.champs_sorted) + competences = sorted(set(competences)) + colonnes_html = competences + + tags = self._do_tags_list(self.interclasstags) + + # Les données par UE + moyennes = {} + for tag in tags: + moyennes[tag] = {} + # Les données de synthèse + df = self.synthese[(tag, pe_moytag.CODE_MOY_COMPETENCES)] + for aggregat in pe_rcs.TOUS_LES_RCS: + # moyennes[tag][aggregat] = {} + descr = pe_rcs.get_descr_rcs(aggregat) + + moy = {} + est_significatif = False + for comp in competences + ["Général"]: + moy[comp] = { + "note": "", + "rang_groupe": "", + "rang_promo": "", + } + colonne = pe_moytag.get_colonne_df( + aggregat, tag, comp, "Groupe", "note" + ) + if colonne in df.columns: + valeur = df.loc[etudid, colonne] + if not np.isnan(valeur): + moy[comp]["note"] = round(valeur, 2) + est_significatif = True + # else: + # print(f"{colonne} manquante") + colonne = pe_moytag.get_colonne_df( + aggregat, tag, comp, "Groupe", "rang" + ) + if colonne in df.columns: + valeur = df.loc[etudid, colonne] + if valeur and str(valeur) != "nan": + moy[comp]["rang_groupe"] = valeur + colonne = pe_moytag.get_colonne_df( + aggregat, tag, comp, "Promo", "rang" + ) + if colonne in df.columns: + valeur = df.loc[etudid, colonne] + if valeur and str(valeur) != "nan": + moy[comp]["rang_promo"] = valeur + + if est_significatif: + moyennes[tag][descr] = moy + + html = template.render( + nom=nom, + prenom=prenom, + parcours=parcours, + colonnes_html=colonnes_html, + tags=tags, + moyennes=moyennes, + ) + + return (nom, prenom, html) -def get_formsemestres_etudiants(etudiants: EtudiantsJuryPE) -> dict: +def get_formsemestres_etudiants(etudiants: pe_etudiant.EtudiantsJuryPE) -> dict: """Ayant connaissance des étudiants dont il faut calculer les moyennes pour - le jury PE (attribut `self.etudiant_ids) et de leur cursus (semestres - parcourus), - renvoie un dictionnaire ``{fid: FormSemestre(fid)}`` + le jury PE (attribut `self.etudiant_ids) et de leurs trajectoires (semestres + parcourus), renvoie un dictionnaire ``{fid: FormSemestre(fid)}`` contenant l'ensemble des formsemestres de leurs cursus, dont il faudra calculer la moyenne. @@ -531,7 +816,6 @@ def get_formsemestres_etudiants(etudiants: EtudiantsJuryPE) -> dict: Returns: Un dictionnaire de la forme `{fid: FormSemestre(fid)}` - """ semestres = {} for etudid in etudiants.etudiants_ids: @@ -541,194 +825,23 @@ def get_formsemestres_etudiants(etudiants: EtudiantsJuryPE) -> dict: return semestres -def compute_semestres_tag(etudiants: EtudiantsJuryPE) -> dict: - """Créé les semestres taggués, de type 'S1', 'S2', ..., pour un groupe d'étudiants donnés. - Chaque semestre taggué est rattaché à l'un des FormSemestre faisant partie du cursus scolaire - des étudiants (cf. attribut etudiants.cursus). - En crééant le semestre taggué, sont calculées les moyennes/classements par tag associé. - . +def convert_colonnes_to_multiindex(df): + """Convertit les colonnes d'un df pour obtenir des colonnes + multiindex""" + df_final = df.copy() + colonnes = list(df.columns) + colonnes = [tuple(col.split("|")) for col in colonnes] + # modifie le nom du semestre par sa descr + colonnes_verbose = [] - Args: - etudiants: Un groupe d'étudiants participant au jury + for col in colonnes: + if col[0] in pe_rcs.TYPES_RCS: + descr = pe_rcs.get_descr_rcs(col[0]) + col_verbose = [descr] + list(col[1:]) + col_verbose = tuple(col_verbose) + else: + col_verbose = col + colonnes_verbose.append(col_verbose) - Returns: - Un dictionnaire {fid: SemestreTag(fid)} - """ - - # Création des semestres taggués, de type 'S1', 'S2', ... - pe_affichage.pe_print("*** Création des semestres taggués") - - formsemestres = get_formsemestres_etudiants(etudiants) - - semestres_tags = {} - for frmsem_id, formsemestre in formsemestres.items(): - # Crée le semestre_tag et exécute les calculs de moyennes - formsemestretag = SemestreTag(frmsem_id) - pe_affichage.pe_print( - f" --> Semestre taggué {formsemestretag.nom} sur la base de {formsemestre}" - ) - # Stocke le semestre taggué - semestres_tags[frmsem_id] = formsemestretag - - return semestres_tags - - -def compute_trajectoires_tag( - trajectoires: RCSsJuryPE, - etudiants: EtudiantsJuryPE, - semestres_taggues: dict[int, SemestreTag], -): - """Créée les trajectoires tagguées (combinaison aggrégeant plusieurs semestres au sens - d'un aggrégat (par ex: '3S')), - en calculant les moyennes et les classements par tag pour chacune. - - Pour rappel : Chaque trajectoire est identifiée un nom d'aggrégat et par un formsemestre terminal. - - Par exemple : - - * combinaisons '3S' : S1+S2+S3 en prenant en compte tous les S3 qu'ont fréquenté les - étudiants du jury PE. Ces S3 marquent les formsemestre terminal de chaque combinaison. - - * combinaisons 'S2' : 1 seul S2 pour des étudiants n'ayant pas redoublé, 2 pour des redoublants (dont les - notes seront moyennées sur leur 2 semestres S2). Ces combinaisons ont pour formsemestre le dernier S2 en - date (le S2 redoublé par les redoublants est forcément antérieur) - - - Args: - etudiants: Les données des étudiants - semestres_tag: Les semestres tag (pour lesquels des moyennes par tag ont été calculés) - - Return: - Un dictionnaire de la forme ``{nom_aggregat: {fid_terminal: SetTag(fid_terminal)} }`` - """ - trajectoires_tagguees = {} - - for trajectoire_id, trajectoire in trajectoires.rcss.items(): - nom = trajectoire.get_repr() - pe_affichage.pe_print(f" --> Aggrégat {nom}") - # Trajectoire_tagguee associée - trajectoire_tagguee = RCSTag(trajectoire, semestres_taggues) - # Mémorise le résultat - trajectoires_tagguees[trajectoire_id] = trajectoire_tagguee - - return trajectoires_tagguees - - -def compute_interclassements( - etudiants: EtudiantsJuryPE, - trajectoires_jury_pe: RCSsJuryPE, - trajectoires_tagguees: dict[tuple, RCS], -): - """Interclasse les étudiants, (nom d') aggrégat par aggrégat, - pour fournir un classement sur la promo. Le classement est établi au regard du nombre - d'étudiants ayant participé au même aggrégat. - """ - aggregats_interclasses_taggues = {} - for nom_aggregat in TOUS_LES_RCS: - pe_affichage.pe_print(f" --> Interclassement {nom_aggregat}") - interclass = RCSInterclasseTag( - nom_aggregat, etudiants, trajectoires_jury_pe, trajectoires_tagguees - ) - aggregats_interclasses_taggues[nom_aggregat] = interclass - return aggregats_interclasses_taggues - - -def get_defaut_dict_synthese_aggregat(nom_rcs: str, diplome: int) -> dict: - """Renvoie le dictionnaire de synthèse (à intégrer dans - un tableur excel) pour décrire les résultats d'un aggrégat - - Args: - nom_rcs : Le nom du RCS visé - diplôme : l'année du diplôme - """ - # L'affichage de l'aggrégat dans le tableur excel - descr = get_descr_rcs(nom_rcs) - - nom_stat_promo = f"{NOM_STAT_PROMO} {diplome}" - donnees = { - (descr, "", "note"): SANS_NOTE, - # Les stat du groupe - (descr, NOM_STAT_GROUPE, "class."): SANS_NOTE, - (descr, NOM_STAT_GROUPE, "min"): SANS_NOTE, - (descr, NOM_STAT_GROUPE, "moy"): SANS_NOTE, - (descr, NOM_STAT_GROUPE, "max"): SANS_NOTE, - # Les stats de l'interclassement dans la promo - (descr, nom_stat_promo, "class."): SANS_NOTE, - ( - descr, - nom_stat_promo, - "min", - ): SANS_NOTE, - ( - descr, - nom_stat_promo, - "moy", - ): SANS_NOTE, - ( - descr, - nom_stat_promo, - "max", - ): SANS_NOTE, - } - return donnees - - -def get_dict_synthese_aggregat( - aggregat: str, - trajectoire_tagguee: RCSTag, - interclassement_taggue: RCSInterclasseTag, - etudid: int, - tag: str, - diplome: int, -): - """Renvoie le dictionnaire (à intégrer au tableur excel de synthese) - traduisant les résultats (moy/class) d'un étudiant à une trajectoire tagguée associée - à l'aggrégat donné et pour un tag donné""" - donnees = {} - # L'affichage de l'aggrégat dans le tableur excel - descr = get_descr_rcs(aggregat) - - # La note de l'étudiant (chargement à venir) - note = np.nan - - # Les données de la trajectoire tagguée pour le tag considéré - moy_tag = trajectoire_tagguee.moyennes_tags[tag] - - # Les données de l'étudiant - note = moy_tag.get_note_for_df(etudid) - - classement = moy_tag.get_class_for_df(etudid) - nmin = moy_tag.get_min_for_df() - nmax = moy_tag.get_max_for_df() - nmoy = moy_tag.get_moy_for_df() - - # Statistiques sur le groupe - if not pd.isna(note) and note != np.nan: - # Les moyennes de cette trajectoire - donnees |= { - (descr, "", "note"): note, - (descr, NOM_STAT_GROUPE, "class."): classement, - (descr, NOM_STAT_GROUPE, "min"): nmin, - (descr, NOM_STAT_GROUPE, "moy"): nmoy, - (descr, NOM_STAT_GROUPE, "max"): nmax, - } - - # L'interclassement - moy_tag = interclassement_taggue.moyennes_tags[tag] - - classement = moy_tag.get_class_for_df(etudid) - nmin = moy_tag.get_min_for_df() - nmax = moy_tag.get_max_for_df() - nmoy = moy_tag.get_moy_for_df() - - if not pd.isna(note) and note != np.nan: - nom_stat_promo = f"{NOM_STAT_PROMO} {diplome}" - - donnees |= { - (descr, nom_stat_promo, "class."): classement, - (descr, nom_stat_promo, "min"): nmin, - (descr, nom_stat_promo, "moy"): nmoy, - (descr, nom_stat_promo, "max"): nmax, - } - - return donnees + df_final.columns = pd.MultiIndex.from_tuples(colonnes_verbose) + return df_final diff --git a/app/pe/pe_rcs.py b/app/pe/pe_rcs.py deleted file mode 100644 index f9ec66883..000000000 --- a/app/pe/pe_rcs.py +++ /dev/null @@ -1,269 +0,0 @@ -############################################################################## -# Module "Avis de poursuite d'étude" -# conçu et développé par Cléo Baras (IUT de Grenoble) -############################################################################## - -""" -Created on 01-2024 - -@author: barasc -""" - -import app.pe.pe_comp as pe_comp - -from app.models import FormSemestre -from app.pe.pe_etudiant import EtudiantsJuryPE, get_dernier_semestre_en_date - - -TYPES_RCS = { - "S1": { - "aggregat": ["S1"], - "descr": "Semestre 1 (S1)", - }, - "S2": { - "aggregat": ["S2"], - "descr": "Semestre 2 (S2)", - }, - "1A": { - "aggregat": ["S1", "S2"], - "descr": "BUT1 (S1+S2)", - }, - "S3": { - "aggregat": ["S3"], - "descr": "Semestre 3 (S3)", - }, - "S4": { - "aggregat": ["S4"], - "descr": "Semestre 4 (S4)", - }, - "2A": { - "aggregat": ["S3", "S4"], - "descr": "BUT2 (S3+S4)", - }, - "3S": { - "aggregat": ["S1", "S2", "S3"], - "descr": "Moyenne du semestre 1 au semestre 3 (S1+S2+S3)", - }, - "4S": { - "aggregat": ["S1", "S2", "S3", "S4"], - "descr": "Moyenne du semestre 1 au semestre 4 (S1+S2+S3+S4)", - }, - "S5": { - "aggregat": ["S5"], - "descr": "Semestre 5 (S5)", - }, - "S6": { - "aggregat": ["S6"], - "descr": "Semestre 6 (S6)", - }, - "3A": { - "aggregat": ["S5", "S6"], - "descr": "3ème année (S5+S6)", - }, - "5S": { - "aggregat": ["S1", "S2", "S3", "S4", "S5"], - "descr": "Moyenne du semestre 1 au semestre 5 (S1+S2+S3+S4+S5)", - }, - "6S": { - "aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"], - "descr": "Moyenne globale (S1+S2+S3+S4+S5+S6)", - }, -} -"""Dictionnaire détaillant les différents regroupements cohérents -de semestres (RCS), en leur attribuant un nom et en détaillant -le nom des semestres qu'ils regroupent et l'affichage qui en sera fait -dans les tableurs de synthèse. -""" - -TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS if not cle.startswith("S")] -TOUS_LES_RCS = list(TYPES_RCS.keys()) -TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS if cle.startswith("S")] - - -class RCS: - """Modélise un ensemble de semestres d'étudiants - associé à un type de regroupement cohérent de semestres - donné (par ex: 'S2', '3S', '2A'). - - Si le RCS est un semestre de type Si, stocke le (ou les) - formsemestres de numéro i qu'ont suivi l'étudiant pour atteindre le Si - (en général 1 si personnes n'a redoublé, mais 2 s'il y a des redoublants) - - Pour le RCS de type iS ou iA (par ex, 3A=S1+S2+S3), elle identifie - les semestres que les étudiants ont suivis pour les amener jusqu'au semestre - terminal de la trajectoire (par ex: ici un S3). - - Ces semestres peuvent être : - - * des S1+S2+S1+S2+S3 si redoublement de la 1ère année - * des S1+S2+(année de césure)+S3 si césure, ... - - Args: - nom_rcs: Un nom du RCS (par ex: '5S') - semestre_final: Le semestre final du RCS - """ - - def __init__(self, nom_rcs: str, semestre_final: FormSemestre): - self.nom = nom_rcs - """Nom du RCS""" - - self.formsemestre_final = semestre_final - """FormSemestre terminal du RCS""" - - self.rcs_id = (nom_rcs, semestre_final.formsemestre_id) - """Identifiant du RCS sous forme (nom_rcs, id du semestre_terminal)""" - - self.semestres_aggreges = {} - """Semestres regroupés dans le RCS""" - - def add_semestres_a_aggreger(self, semestres: dict[int:FormSemestre]): - """Ajout de semestres aux semestres à regrouper - - Args: - semestres: Dictionnaire ``{fid: FormSemestre(fid)}`` à ajouter - """ - self.semestres_aggreges = self.semestres_aggreges | semestres - - def get_repr(self, verbose=True) -> str: - """Représentation textuelle d'un RCS - basé sur ses semestres aggrégés""" - - noms = [] - for fid in self.semestres_aggreges: - semestre = self.semestres_aggreges[fid] - noms.append(f"S{semestre.semestre_id}({fid})") - noms = sorted(noms) - title = f"""{self.nom} ({ - self.formsemestre_final.formsemestre_id}) {self.formsemestre_final.date_fin.year}""" - if verbose and noms: - title += " - " + "+".join(noms) - return title - - -class RCSsJuryPE: - """Classe centralisant toutes les regroupements cohérents de - semestres (RCS) des étudiants à prendre en compte dans un jury PE - - Args: - annee_diplome: L'année de diplomation - """ - - def __init__(self, annee_diplome: int): - self.annee_diplome = annee_diplome - """Année de diplômation""" - - self.rcss: dict[tuple:RCS] = {} - """Ensemble des RCS recensés : {(nom_RCS, fid_terminal): RCS}""" - - self.suivi: dict[int:str] = {} - """Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS, - son RCS : {etudid: {nom_RCS: RCS}}""" - - def cree_rcss(self, etudiants: EtudiantsJuryPE): - """Créé tous les RCS, au regard du cursus des étudiants - analysés + les mémorise dans les données de l'étudiant - - Args: - etudiants: Les étudiants à prendre en compte dans le Jury PE - """ - - for nom_rcs in pe_comp.TOUS_LES_SEMESTRES + TOUS_LES_RCS_AVEC_PLUSIEURS_SEM: - # L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre - # terminal (par ex: S3) et son numéro (par ex: 3) - noms_semestre_de_aggregat = TYPES_RCS[nom_rcs]["aggregat"] - nom_semestre_terminal = noms_semestre_de_aggregat[-1] - - for etudid in etudiants.cursus: - if etudid not in self.suivi: - self.suivi[etudid] = { - aggregat: None - for aggregat in pe_comp.TOUS_LES_SEMESTRES - + TOUS_LES_RCS_AVEC_PLUSIEURS_SEM - } - - # Le formsemestre terminal (dernier en date) associé au - # semestre marquant la fin de l'aggrégat - # (par ex: son dernier S3 en date) - semestres = etudiants.cursus[etudid][nom_semestre_terminal] - if semestres: - formsemestre_final = get_dernier_semestre_en_date(semestres) - - # Ajout ou récupération de la trajectoire - trajectoire_id = (nom_rcs, formsemestre_final.formsemestre_id) - if trajectoire_id not in self.rcss: - trajectoire = RCS(nom_rcs, formsemestre_final) - self.rcss[trajectoire_id] = trajectoire - else: - trajectoire = self.rcss[trajectoire_id] - - # La liste des semestres de l'étudiant à prendre en compte - # pour cette trajectoire - semestres_a_aggreger = get_rcs_etudiant( - etudiants.cursus[etudid], formsemestre_final, nom_rcs - ) - - # Ajout des semestres à la trajectoire - trajectoire.add_semestres_a_aggreger(semestres_a_aggreger) - - # Mémoire la trajectoire suivie par l'étudiant - self.suivi[etudid][nom_rcs] = trajectoire - - -def get_rcs_etudiant( - semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str -) -> dict[int, FormSemestre]: - """Ensemble des semestres parcourus par un étudiant, connaissant - les semestres de son cursus, - dans le cadre du RCS visé et ayant pour semestre terminal `formsemestre_final`. - - Si le RCS est de type "Si", limite les semestres à ceux de numéro i. - Par ex: si formsemestre_terminal est un S3 et nom_agrregat "S3", ne prend en compte que les - semestres 3. - - Si le RCS est de type "iA" ou "iS" (incluant plusieurs numéros de semestres), prend en - compte les dit numéros de semestres. - - Par ex: si formsemestre_terminal est un S3, ensemble des S1, - S2, S3 suivi pour l'amener au S3 (il peut y avoir plusieurs S1, - ou S2, ou S3 s'il a redoublé). - - Les semestres parcourus sont antérieurs (en terme de date de fin) - au formsemestre_terminal. - - Args: - cursus: Dictionnaire {fid: FormSemestre(fid)} donnant l'ensemble des semestres - dans lesquels l'étudiant a été inscrit - formsemestre_final: le semestre final visé - nom_rcs: Nom du RCS visé - """ - numero_semestre_terminal = formsemestre_final.semestre_id - # semestres_significatifs = self.get_semestres_significatifs(etudid) - semestres_significatifs = {} - for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1): - semestres_significatifs = semestres_significatifs | semestres[f"S{i}"] - - if nom_rcs.startswith("S"): # les semestres - numero_semestres_possibles = [numero_semestre_terminal] - elif nom_rcs.endswith("A"): # les années - numero_semestres_possibles = [ - int(sem[-1]) for sem in TYPES_RCS[nom_rcs]["aggregat"] - ] - assert numero_semestre_terminal in numero_semestres_possibles - else: # les xS = tous les semestres jusqu'à Sx (eg S1, S2, S3 pour un S3 terminal) - numero_semestres_possibles = list(range(1, numero_semestre_terminal + 1)) - - semestres_aggreges = {} - for fid, semestre in semestres_significatifs.items(): - # Semestres parmi ceux de n° possibles & qui lui sont antérieurs - if ( - semestre.semestre_id in numero_semestres_possibles - and semestre.date_fin <= formsemestre_final.date_fin - ): - semestres_aggreges[fid] = semestre - return semestres_aggreges - - -def get_descr_rcs(nom_rcs: str) -> str: - """Renvoie la description pour les tableurs de synthèse - Excel d'un nom de RCS""" - return TYPES_RCS[nom_rcs]["descr"] diff --git a/app/pe/pe_rcss_jury.py b/app/pe/pe_rcss_jury.py new file mode 100644 index 000000000..db61e3a16 --- /dev/null +++ b/app/pe/pe_rcss_jury.py @@ -0,0 +1,289 @@ +import app.pe.pe_comp +from app.pe.rcss import pe_rcs, pe_trajectoires, pe_rcsemx +import app.pe.pe_etudiant as pe_etudiant +import app.pe.pe_comp as pe_comp +from app.models import FormSemestre +from app.pe import pe_affichage + + +class RCSsJuryPE: + """Classe centralisant tous les regroupements cohérents de + semestres (RCS) des étudiants à prendre en compte dans un jury PE + + Args: + annee_diplome: L'année de diplomation + """ + + def __init__(self, annee_diplome: int, etudiants: pe_etudiant.EtudiantsJuryPE): + self.annee_diplome = annee_diplome + """Année de diplômation""" + + self.etudiants = etudiants + """Les étudiants recensés""" + + self.trajectoires: dict[tuple(int, str) : pe_trajectoires.Trajectoire] = {} + """Ensemble des trajectoires recensées (regroupement de (form)semestres BUT)""" + + self.trajectoires_suivies: dict[int:dict] = {} + """Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS, + sa Trajectoire : {etudid: {nom_RCS: Trajectoire}}""" + + self.semXs: dict[tuple(int, str) : pe_trajectoires.SemX] = {} + """Ensemble des SemX recensés (regroupement de (form)semestre BUT de rang x) : + {(nom_RCS, fid_terminal): SemX}""" + + self.semXs_suivis: dict[int:dict] = {} + """Dictionnaire associant, pour chaque étudiant et pour chaque RCS de type Sx, + son SemX : {etudid: {nom_RCS_de_type_Sx: SemX}}""" + + self.rcsemxs: dict[tuple(int, str) : pe_rcsemx.RCSemX] = {} + """Ensemble des RCSemX (regroupement de SemX donnant les résultats aux sems de rang x) + recensés : {(nom_RCS, fid_terminal): RCSemX}""" + + self.rcsemxs_suivis: dict[int:str] = {} + """Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS, + son RCSemX : {etudid: {nom_RCS: RCSemX}}""" + + def cree_trajectoires(self): + """Créé toutes les trajectoires, au regard du cursus des étudiants + analysés + les mémorise dans les données de l'étudiant + + Args: + etudiants: Les étudiants à prendre en compte dans le Jury PE + """ + + tous_les_aggregats = pe_rcs.TOUS_LES_RCS + + for etudid in self.etudiants.cursus: + self.trajectoires_suivies[etudid] = self.etudiants.trajectoires[etudid] + + for nom_rcs in tous_les_aggregats: + # L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre + # terminal (par ex: S3) et son numéro (par ex: 3) + noms_semestres = pe_rcs.TYPES_RCS[nom_rcs]["aggregat"] + nom_semestre_final = noms_semestres[-1] + + for etudid in self.etudiants.cursus: + # Le (ou les) semestre(s) marquant la fin du cursus de l'étudiant + sems_final = self.etudiants.cursus[etudid][nom_semestre_final] + if sems_final: + # Le formsemestre final (dernier en date) de l'étudiant, + # marquant la fin de son aggrégat (par ex: son dernier S3 en date) + formsemestre_final = app.pe.pe_comp.get_dernier_semestre_en_date( + sems_final + ) + + # Ajout (si nécessaire) et récupération du RCS associé + rcs_id = (nom_rcs, formsemestre_final.formsemestre_id) + if rcs_id not in self.trajectoires: + self.trajectoires[rcs_id] = pe_trajectoires.Trajectoire( + nom_rcs, formsemestre_final + ) + rcs = self.trajectoires[rcs_id] + + # La liste des semestres de l'étudiant à prendre en compte + # pour cette trajectoire + semestres_a_aggreger = get_rcs_etudiant( + self.etudiants.cursus[etudid], formsemestre_final, nom_rcs + ) + + # Ajout des semestres au RCS + rcs.add_semestres(semestres_a_aggreger) + + # Mémorise le RCS suivi par l'étudiant + self.trajectoires_suivies[etudid][nom_rcs] = rcs + self.etudiants.trajectoires[etudid][nom_rcs] = rcs + + def cree_semxs(self): + """Créé les SemXs (trajectoires/combinaisons de semestre de même rang x), + en ne conservant dans les trajectoires que les regroupements + de type Sx""" + self.semXs = {} + for rcs_id, trajectoire in self.trajectoires.items(): + if trajectoire.nom in pe_rcs.TOUS_LES_SEMESTRES: + self.semXs[rcs_id] = pe_trajectoires.SemX(trajectoire) + + # L'association (pour chaque étudiant entre chaque Sx et le SemX associé) + self.semXs_suivis = {} + for etudid in self.etudiants.trajectoires: + self.semXs_suivis[etudid] = { + agregat: None for agregat in pe_rcs.TOUS_LES_SEMESTRES + } + for agregat in pe_rcs.TOUS_LES_SEMESTRES: + trajectoire = self.etudiants.trajectoires[etudid][agregat] + if trajectoire: + rcs_id = trajectoire.rcs_id + semX = self.semXs[rcs_id] + self.semXs_suivis[etudid][agregat] = semX + self.etudiants.semXs[etudid][agregat] = semX + + def cree_rcsemxs(self, options={"moyennes_ues_rcues": True}): + """Créé tous les RCSemXs, au regard du cursus des étudiants + analysés (trajectoires traduisant son parcours dans les + différents semestres) + les mémorise dans les données de l'étudiant + """ + self.rcsemxs_suivis = {} + self.rcsemxs = {} + + if "moyennes_ues_rcues" in options and options["moyennes_ues_rcues"] == False: + # Pas de RCSemX généré + pe_affichage.pe_print("⚠️ Pas de RCSemX générés") + return + + # Pour tous les étudiants du jury + pas_de_semestres = [] + for etudid in self.trajectoires_suivies: + self.rcsemxs_suivis[etudid] = { + nom_rcs: None for nom_rcs in pe_rcs.TOUS_LES_RCS_AVEC_PLUSIEURS_SEM + } + + # Pour chaque aggréggat de type xA ou Sx ou xS + for agregat in pe_rcs.TOUS_LES_RCS: + trajectoire = self.trajectoires_suivies[etudid][agregat] + if not trajectoire: + self.rcsemxs_suivis[etudid][agregat] = None + else: + # Identifiant de la trajectoire => donnera ceux du RCSemX + tid = trajectoire.rcs_id + # Ajout du RCSemX + if tid not in self.rcsemxs: + self.rcsemxs[tid] = pe_rcsemx.RCSemX( + trajectoire.nom, trajectoire.formsemestre_final + ) + + # Récupére les SemX (RC de type Sx) associés aux semestres de son cursus + # Par ex: dans S1+S2+S1+S2+S3 => les 2 S1 devient le SemX('S1'), les 2 S2 le SemX('S2'), etc.. + + # Les Sx pris en compte dans l'aggrégat + noms_sems_aggregat = pe_rcs.TYPES_RCS[agregat]["aggregat"] + + semxs_a_aggreger = {} + for Sx in noms_sems_aggregat: + semestres_etudiants = self.etudiants.cursus[etudid][Sx] + if not semestres_etudiants: + pas_de_semestres += [ + f"{Sx} pour {self.etudiants.identites[etudid].nomprenom}" + ] + else: + semx_id = get_semx_from_semestres_aggreges( + self.semXs, semestres_etudiants + ) + if not semx_id: + raise ( + "Il manque un SemX pour créer les RCSemX dans cree_rcsemxs" + ) + # Les SemX à ajouter au RCSemX + semxs_a_aggreger[semx_id] = self.semXs[semx_id] + + # Ajout des SemX à ceux à aggréger dans le RCSemX + rcsemx = self.rcsemxs[tid] + rcsemx.add_semXs(semxs_a_aggreger) + + # Mémoire du RCSemX aux informations de suivi de l'étudiant + self.rcsemxs_suivis[etudid][agregat] = rcsemx + self.etudiants.rcsemXs[etudid][agregat] = rcsemx + + # Affichage des étudiants pour lesquels il manque un semestre + pas_de_semestres = sorted(set(pas_de_semestres)) + if pas_de_semestres: + pe_affichage.pe_print("⚠️ Semestres manquants :") + pe_affichage.pe_print( + "\n".join([" " * 10 + psd for psd in pas_de_semestres]) + ) + + +def get_rcs_etudiant( + semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str +) -> dict[int, FormSemestre]: + """Ensemble des semestres parcourus (trajectoire) + par un étudiant dans le cadre + d'un RCS de type Sx, iA ou iS et ayant pour semestre terminal `formsemestre_final`. + + Par ex: pour un RCS "3S", dont le formsemestre_terminal est un S3, regroupe + le ou les S1 qu'il a suivi (1 ou 2 si redoublement) + le ou les S2 + le ou les S3. + + Les semestres parcourus sont antérieurs (en terme de date de fin) + au formsemestre_terminal. + + Args: + cursus: Dictionnaire {fid: Formsemestre} donnant l'ensemble des semestres + dans lesquels l'étudiant a été inscrit + formsemestre_final: le semestre final visé + nom_rcs: Nom du RCS visé + """ + numero_semestre_terminal = formsemestre_final.semestre_id + # semestres_significatifs = self.get_semestres_significatifs(etudid) + semestres_significatifs = {} + for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1): + semestres_significatifs = semestres_significatifs | semestres[f"S{i}"] + + if nom_rcs.startswith("S"): # les semestres + numero_semestres_possibles = [numero_semestre_terminal] + elif nom_rcs.endswith("A"): # les années + numero_semestres_possibles = [ + int(sem[-1]) for sem in pe_rcs.TYPES_RCS[nom_rcs]["aggregat"] + ] + assert numero_semestre_terminal in numero_semestres_possibles + else: # les xS = tous les semestres jusqu'à Sx (eg S1, S2, S3 pour un S3 terminal) + numero_semestres_possibles = list(range(1, numero_semestre_terminal + 1)) + + semestres_aggreges = {} + for fid, semestre in semestres_significatifs.items(): + # Semestres parmi ceux de n° possibles & qui lui sont antérieurs + if ( + semestre.semestre_id in numero_semestres_possibles + and semestre.date_fin <= formsemestre_final.date_fin + ): + semestres_aggreges[fid] = semestre + return semestres_aggreges + + +def get_semx_from_semestres_aggreges( + semXs: dict[(str, int) : pe_trajectoires.SemX], + semestres_a_aggreger: dict[(str, int):FormSemestre], +) -> (str, int): + """Partant d'un dictionnaire de SemX (de la forme + ``{ (nom_rcs, fid): SemX }, et connaissant une liste + de (form)semestres suivis, renvoie l'identifiant + (nom_rcs, fid) du SemX qui lui correspond. + + Le SemX qui correspond est tel que : + + * le semestre final du SemX correspond au dernier semestre en date des + semestres_a_aggreger + * le rang du SemX est le même que celui des semestres_aggreges + * les semestres_a_aggreger (plus large, car contenant plusieurs + parcours), matchent avec les semestres aggrégés + par le SemX + + + Returns: + rcf_id: L'identifiant du RCF trouvé + """ + assert semestres_a_aggreger, "Pas de semestres à aggréger" + rangs_a_aggreger = [sem.semestre_id for fid, sem in semestres_a_aggreger.items()] + assert ( + len(set(rangs_a_aggreger)) == 1 + ), "Tous les sem à aggréger doivent être de même rang" + + # Le dernier semestre des semestres à regrouper + dernier_sem_a_aggreger = pe_comp.get_dernier_semestre_en_date(semestres_a_aggreger) + + semxs_ids = [] # Au cas où il y ait plusieurs solutions + for semx_id, semx in semXs.items(): + # Même semestre final ? + if semx.get_formsemestre_id_final() == dernier_sem_a_aggreger.formsemestre_id: + # Les fids + fids_a_aggreger = set(semestres_a_aggreger.keys()) + # Ceux du semx + fids_semx = set(semx.semestres_aggreges.keys()) + if fids_a_aggreger.issubset( + fids_semx + ): # tous les semestres du semx correspond à des sems de la trajectoire + semxs_ids += [semx_id] + if len(semxs_ids) == 0: + return None # rien trouvé + elif len(semxs_ids) == 1: + return semxs_ids[0] + else: + raise "Plusieurs solutions :)" diff --git a/app/pe/pe_rcstag.py b/app/pe/pe_rcstag.py deleted file mode 100644 index c3d3a05fd..000000000 --- a/app/pe/pe_rcstag.py +++ /dev/null @@ -1,217 +0,0 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -############################################################################## -# Module "Avis de poursuite d'étude" -# conçu et développé par Cléo Baras (IUT de Grenoble) -############################################################################## - -""" -Created on Fri Sep 9 09:15:05 2016 - -@author: barasc -""" - -from app.comp.res_sem import load_formsemestre_results -from app.pe.pe_semtag import SemestreTag -import pandas as pd -import numpy as np -from app.pe.pe_rcs import RCS - -from app.pe.pe_tabletags import TableTag, MoyenneTag - - -class RCSTag(TableTag): - def __init__( - self, rcs: RCS, semestres_taggues: dict[int, SemestreTag] - ): - """Calcule les moyennes par tag d'une combinaison de semestres - (RCS), pour extraire les classements par tag pour un - groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous - participé au semestre terminal. - - - Args: - rcs: Un RCS (identifié par un nom et l'id de son semestre terminal) - semestres_taggues: Les données sur les semestres taggués - """ - TableTag.__init__(self) - - - self.rcs_id = rcs.rcs_id - """Identifiant du RCS taggué (identique au RCS sur lequel il s'appuie)""" - - self.rcs = rcs - """RCS associé au RCS taggué""" - - self.nom = self.get_repr() - """Représentation textuelle du RCS taggué""" - - self.formsemestre_terminal = rcs.formsemestre_final - """Le formsemestre terminal""" - - # Les résultats du formsemestre terminal - nt = load_formsemestre_results(self.formsemestre_terminal) - - self.semestres_aggreges = rcs.semestres_aggreges - """Les semestres aggrégés""" - - self.semestres_tags_aggreges = {} - """Les semestres tags associés aux semestres aggrégés""" - for frmsem_id in self.semestres_aggreges: - try: - self.semestres_tags_aggreges[frmsem_id] = semestres_taggues[frmsem_id] - except: - raise ValueError("Semestres taggués manquants") - - """Les étudiants (état civil + cursus connu)""" - self.etuds = nt.etuds - - # assert self.etuds == trajectoire.suivi # manque-t-il des étudiants ? - self.etudiants = {etud.etudid: etud.etat_civil for etud in self.etuds} - - self.tags_sorted = self.do_taglist() - """Tags extraits de tous les semestres""" - - self.notes_cube = self.compute_notes_cube() - """Cube de notes""" - - etudids = list(self.etudiants.keys()) - self.notes = compute_tag_moy(self.notes_cube, etudids, self.tags_sorted) - """Calcul les moyennes par tag sous forme d'un dataframe""" - - self.moyennes_tags: dict[str, MoyenneTag] = {} - """Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)""" - for tag in self.tags_sorted: - moy_gen_tag = self.notes[tag] - self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag) - - def __eq__(self, other): - """Egalité de 2 RCS taggués sur la base de leur identifiant""" - return self.rcs_id == other.rcs_id - - def get_repr(self, verbose=False) -> str: - """Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle - est basée)""" - return self.rcs.get_repr(verbose=verbose) - - def compute_notes_cube(self): - """Construit le cube de notes (etudid x tags x semestre_aggregé) - nécessaire au calcul des moyennes de l'aggrégat - """ - # nb_tags = len(self.tags_sorted) - # nb_etudiants = len(self.etuds) - # nb_semestres = len(self.semestres_tags_aggreges) - - # Index du cube (etudids -> dim 0, tags -> dim 1) - etudids = [etud.etudid for etud in self.etuds] - tags = self.tags_sorted - semestres_id = list(self.semestres_tags_aggreges.keys()) - - dfs = {} - - for frmsem_id in semestres_id: - # Partant d'un dataframe vierge - df = pd.DataFrame(np.nan, index=etudids, columns=tags) - - # Charge les notes du semestre tag - notes = self.semestres_tags_aggreges[frmsem_id].notes - - # Les étudiants & les tags commun au dataframe final et aux notes du semestre) - etudids_communs = df.index.intersection(notes.index) - tags_communs = df.columns.intersection(notes.columns) - - # Injecte les notes par tag - df.loc[etudids_communs, tags_communs] = notes.loc[ - etudids_communs, tags_communs - ] - - # Supprime tout ce qui n'est pas numérique - for col in df.columns: - df[col] = pd.to_numeric(df[col], errors="coerce") - - # Stocke le df - dfs[frmsem_id] = df - - """Réunit les notes sous forme d'un cube etdids x tags x semestres""" - semestres_x_etudids_x_tags = [dfs[fid].values for fid in dfs] - etudids_x_tags_x_semestres = np.stack(semestres_x_etudids_x_tags, axis=-1) - - return etudids_x_tags_x_semestres - - def do_taglist(self): - """Synthétise les tags à partir des semestres (taggués) aggrégés - - Returns: - Une liste de tags triés par ordre alphabétique - """ - tags = [] - for frmsem_id in self.semestres_tags_aggreges: - tags.extend(self.semestres_tags_aggreges[frmsem_id].tags_sorted) - return sorted(set(tags)) - - -def compute_tag_moy(set_cube: np.array, etudids: list, tags: list): - """Calcul de la moyenne par tag sur plusieurs semestres. - La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles - - *Remarque* : Adaptation de moy_ue.compute_ue_moys_apc au cas des moyennes de tag - par aggrégat de plusieurs semestres. - - Args: - set_cube: notes moyennes aux modules ndarray - (etuds x modimpls x UEs), des floats avec des NaN - etudids: liste des étudiants (dim. 0 du cube) - tags: liste des tags (dim. 1 du cube) - Returns: - Un DataFrame avec pour columns les moyennes par tags, - et pour rows les etudid - """ - nb_etuds, nb_tags, nb_semestres = set_cube.shape - assert nb_etuds == len(etudids) - assert nb_tags == len(tags) - - # Quelles entrées du cube contiennent des notes ? - mask = ~np.isnan(set_cube) - - # Enlève les NaN du cube pour les entrées manquantes - set_cube_no_nan = np.nan_to_num(set_cube, nan=0.0) - - # Les moyennes par tag - with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) - etud_moy_tag = np.sum(set_cube_no_nan, axis=2) / np.sum(mask, axis=2) - - # Le dataFrame - etud_moy_tag_df = pd.DataFrame( - etud_moy_tag, - index=etudids, # les etudids - columns=tags, # les tags - ) - - etud_moy_tag_df.fillna(np.nan) - - return etud_moy_tag_df diff --git a/app/pe/pe_semtag.py b/app/pe/pe_semtag.py deleted file mode 100644 index 9ed2418b6..000000000 --- a/app/pe/pe_semtag.py +++ /dev/null @@ -1,310 +0,0 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -############################################################################## -# Module "Avis de poursuite d'étude" -# conçu et développé par Cléo Baras (IUT de Grenoble) -############################################################################## - -""" -Created on Fri Sep 9 09:15:05 2016 - -@author: barasc -""" -import pandas as pd - -import app.pe.pe_etudiant -from app import db, ScoValueError -from app import comp -from app.comp.res_sem import load_formsemestre_results -from app.models import FormSemestre -from app.models.moduleimpls import ModuleImpl -import app.pe.pe_affichage as pe_affichage -from app.pe.pe_tabletags import TableTag, MoyenneTag -from app.scodoc import sco_tag_module -from app.scodoc.codes_cursus import UE_SPORT - - -class SemestreTag(TableTag): - """ - Un SemestreTag représente les résultats des étudiants à un semestre, en donnant - accès aux moyennes par tag. - Il s'appuie principalement sur FormSemestre et sur ResultatsSemestreBUT. - """ - - def __init__(self, formsemestre_id: int): - """ - Args: - formsemestre_id: Identifiant du ``FormSemestre`` sur lequel il se base - """ - TableTag.__init__(self) - - # Le semestre - self.formsemestre_id = formsemestre_id - self.formsemestre = FormSemestre.get_formsemestre(formsemestre_id) - - # Le nom du semestre taggué - self.nom = self.get_repr() - - # Les résultats du semestre - self.nt = load_formsemestre_results(self.formsemestre) - - # Les étudiants - self.etuds = self.nt.etuds - self.etudiants = {etud.etudid: etud.etat_civil for etud in self.etuds} - - # Les notes, les modules implémentés triés, les étudiants, les coeffs, - # récupérés notamment de py:mod:`res_but` - self.sem_cube = self.nt.sem_cube - self.modimpls_sorted = self.nt.formsemestre.modimpls_sorted - self.modimpl_coefs_df = self.nt.modimpl_coefs_df - - # Les inscriptions au module et les dispenses d'UE - self.modimpl_inscr_df = self.nt.modimpl_inscr_df - self.ues = self.nt.ues - self.ues_inscr_parcours_df = self.nt.load_ues_inscr_parcours() - self.dispense_ues = self.nt.dispense_ues - - # Les tags : - ## Saisis par l'utilisateur - tags_personnalises = get_synthese_tags_personnalises_semestre( - self.nt.formsemestre - ) - noms_tags_perso = list(set(tags_personnalises.keys())) - - ## Déduit des compétences - dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre) - noms_tags_comp = list(set(dict_ues_competences.values())) - noms_tags_auto = ["but"] + noms_tags_comp - self.tags = noms_tags_perso + noms_tags_auto - """Tags du semestre taggué""" - - ## Vérifie l'unicité des tags - if len(set(self.tags)) != len(self.tags): - intersection = list(set(noms_tags_perso) & set(noms_tags_auto)) - liste_intersection = "\n".join( - [f"
  • {tag}
  • " for tag in intersection] - ) - s = "s" if len(intersection) > 0 else "" - message = f"""Erreur dans le module PE : Un des tags saisis dans votre - programme de formation fait parti des tags réservés. En particulier, - votre semestre {self.formsemestre.titre_annee()} - contient le{s} tag{s} réservé{s} suivant : -
      - {liste_intersection} -
    - Modifiez votre programme de formation pour le{s} supprimer. - Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études. - """ - raise ScoValueError(message) - - # Calcul des moyennes & les classements de chaque étudiant à chaque tag - self.moyennes_tags = {} - - for tag in tags_personnalises: - # pe_affichage.pe_print(f" -> Traitement du tag {tag}") - moy_gen_tag = self.compute_moyenne_tag(tag, tags_personnalises) - self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag) - - # Ajoute les moyennes générales de BUT pour le semestre considéré - moy_gen_but = self.nt.etud_moy_gen - self.moyennes_tags["but"] = MoyenneTag("but", moy_gen_but) - - # Ajoute les moyennes par compétence - for ue_id, competence in dict_ues_competences.items(): - if competence not in self.moyennes_tags: - moy_ue = self.nt.etud_moy_ue[ue_id] - self.moyennes_tags[competence] = MoyenneTag(competence, moy_ue) - - self.tags_sorted = self.get_all_tags() - """Tags (personnalisés+compétences) par ordre alphabétique""" - - # Synthétise l'ensemble des moyennes dans un dataframe - - self.notes = self.df_notes() - """Dataframe synthétique des notes par tag""" - - pe_affichage.pe_print( - f" => Traitement des tags {', '.join(self.tags_sorted)}" - ) - - def get_repr(self): - """Nom affiché pour le semestre taggué""" - return app.pe.pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True) - - def compute_moyenne_tag(self, tag: str, tags_infos: dict) -> pd.Series: - """Calcule la moyenne des étudiants pour le tag indiqué, - pour ce SemestreTag, en ayant connaissance des informations sur - les tags (dictionnaire donnant les coeff de repondération) - - Sont pris en compte les modules implémentés associés au tag, - avec leur éventuel coefficient de **repondération**, en utilisant les notes - chargées pour ce SemestreTag. - - Force ou non le calcul de la moyenne lorsque des notes sont manquantes. - - Returns: - La série des moyennes - """ - - # Adaptation du mask de calcul des moyennes au tag visé - modimpls_mask = [ - modimpl.module.ue.type != UE_SPORT - for modimpl in self.formsemestre.modimpls_sorted - ] - - # Désactive tous les modules qui ne sont pas pris en compte pour ce tag - for i, modimpl in enumerate(self.formsemestre.modimpls_sorted): - if modimpl.moduleimpl_id not in tags_infos[tag]: - modimpls_mask[i] = False - - # Applique la pondération des coefficients - modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy() - for modimpl_id in tags_infos[tag]: - ponderation = tags_infos[tag][modimpl_id]["ponderation"] - modimpl_coefs_ponderes_df[modimpl_id] *= ponderation - - # Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)# - moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc( - self.sem_cube, - self.etuds, - self.formsemestre.modimpls_sorted, - self.modimpl_inscr_df, - modimpl_coefs_ponderes_df, - modimpls_mask, - self.dispense_ues, - block=self.formsemestre.block_moyennes, - ) - - # Les ects - ects = self.ues_inscr_parcours_df.fillna(0.0) * [ - ue.ects for ue in self.ues if ue.type != UE_SPORT - ] - - # Calcule la moyenne générale dans le semestre (pondérée par le ECTS) - moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects( - moyennes_ues_tag, - ects, - formation_id=self.formsemestre.formation_id, - skip_empty_ues=True, - ) - - return moy_gen_tag - - -def get_moduleimpl(modimpl_id) -> dict: - """Renvoie l'objet modimpl dont l'id est modimpl_id""" - modimpl = db.session.get(ModuleImpl, modimpl_id) - if modimpl: - return modimpl - return None - - -def get_moy_ue_from_nt(nt, etudid, modimpl_id) -> float: - """Renvoie la moyenne de l'UE d'un etudid dans laquelle se trouve - le module de modimpl_id - """ - # ré-écrit - modimpl = get_moduleimpl(modimpl_id) # le module - ue_status = nt.get_etud_ue_status(etudid, modimpl.module.ue.id) - if ue_status is None: - return None - return ue_status["moy"] - - -def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre): - """Etant données les implémentations des modules du semestre (modimpls), - synthétise les tags renseignés dans le programme pédagogique & - associés aux modules du semestre, - en les associant aux modimpls qui les concernent (modimpl_id) et - aucoeff et pondération fournie avec le tag (par défaut 1 si non indiquée)). - - - Args: - formsemestre: Le formsemestre à la base de la recherche des tags - - Return: - Un dictionnaire de tags - """ - synthese_tags = {} - - # Instance des modules du semestre - modimpls = formsemestre.modimpls_sorted - - for modimpl in modimpls: - modimpl_id = modimpl.id - - # Liste des tags pour le module concerné - tags = sco_tag_module.module_tag_list(modimpl.module.id) - - # Traitement des tags recensés, chacun pouvant étant de la forme - # "mathématiques", "théorie", "pe:0", "maths:2" - for tag in tags: - # Extraction du nom du tag et du coeff de pondération - (tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag) - - # Ajout d'une clé pour le tag - if tagname not in synthese_tags: - synthese_tags[tagname] = {} - - # Ajout du module (modimpl) au tagname considéré - synthese_tags[tagname][modimpl_id] = { - "modimpl": modimpl, # les données sur le module - # "coeff": modimpl.module.coefficient, # le coeff du module dans le semestre - "ponderation": ponderation, # la pondération demandée pour le tag sur le module - # "module_code": modimpl.module.code, # le code qui doit se retrouver à l'identique dans des ue capitalisee - # "ue_id": modimpl.module.ue.id, # les données sur l'ue - # "ue_code": modimpl.module.ue.ue_code, - # "ue_acronyme": modimpl.module.ue.acronyme, - } - - return synthese_tags - - -def get_noms_competences_from_ues(formsemestre: FormSemestre) -> dict[int, str]: - """Partant d'un formsemestre, extrait le nom des compétences associés - à (ou aux) parcours des étudiants du formsemestre. - - Ignore les UEs non associées à un niveau de compétence. - - Args: - formsemestre: Un FormSemestre - - Returns: - Dictionnaire {ue_id: nom_competence} lisant tous les noms des compétences - en les raccrochant à leur ue - """ - # Les résultats du semestre - nt = load_formsemestre_results(formsemestre) - - noms_competences = {} - for ue in nt.ues: - if ue.niveau_competence and ue.type != UE_SPORT: - # ?? inutilisé ordre = ue.niveau_competence.ordre - nom = ue.niveau_competence.competence.titre - noms_competences[ue.ue_id] = f"comp. {nom}" - return noms_competences diff --git a/app/pe/pe_tabletags.py b/app/pe/pe_tabletags.py deleted file mode 100644 index 68be87727..000000000 --- a/app/pe/pe_tabletags.py +++ /dev/null @@ -1,263 +0,0 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -############################################################################## -# Module "Avis de poursuite d'étude" -# conçu et développé par Cléo Baras (IUT de Grenoble) -############################################################################## - - -""" -Created on Thu Sep 8 09:36:33 2016 - -@author: barasc -""" - -import datetime -import numpy as np - -from app import ScoValueError -from app.comp.moy_sem import comp_ranks_series -from app.pe import pe_affichage -from app.pe.pe_affichage import SANS_NOTE -from app.scodoc import sco_utils as scu -import pandas as pd - - -TAGS_RESERVES = ["but"] - - -class MoyenneTag: - def __init__(self, tag: str, notes: pd.Series): - """Classe centralisant la synthèse des moyennes/classements d'une série - d'étudiants à un tag donné, en stockant un dictionnaire : - - `` - { - "notes": la Serie pandas des notes (float), - "classements": la Serie pandas des classements (float), - "min": la note minimum, - "max": la note maximum, - "moy": la moyenne, - "nb_inscrits": le nombre d'étudiants ayant une note, - } - `` - - Args: - tag: Un tag - note: Une série de notes (moyenne) sous forme d'un pd.Series() - """ - self.tag = tag - """Le tag associé à la moyenne""" - self.etudids = list(notes.index) # calcul à venir - """Les id des étudiants""" - self.inscrits_ids = notes[notes.notnull()].index.to_list() - """Les id des étudiants dont la moyenne est non nulle""" - self.df: pd.DataFrame = self.comp_moy_et_stat(notes) - """Le dataframe retraçant les moyennes/classements/statistiques""" - self.synthese = self.to_dict() - """La synthèse (dictionnaire) des notes/classements/statistiques""" - - def __eq__(self, other): - """Egalité de deux MoyenneTag lorsque leur tag sont identiques""" - return self.tag == other.tag - - def comp_moy_et_stat(self, notes: pd.Series) -> dict: - """Calcule et structure les données nécessaires au PE pour une série - de notes (souvent une moyenne par tag) dans un dictionnaire spécifique. - - Partant des notes, sont calculés les classements (en ne tenant compte - que des notes non nulles). - - Args: - notes: Une série de notes (avec des éventuels NaN) - - Returns: - Un dictionnaire stockant les notes, les classements, le min, - le max, la moyenne, le nb de notes (donc d'inscrits) - """ - df = pd.DataFrame( - np.nan, - index=self.etudids, - columns=[ - "note", - "classement", - "rang", - "min", - "max", - "moy", - "nb_etuds", - "nb_inscrits", - ], - ) - - # Supprime d'éventuelles chaines de caractères dans les notes - notes = pd.to_numeric(notes, errors="coerce") - df["note"] = notes - - # Les nb d'étudiants & nb d'inscrits - df["nb_etuds"] = len(self.etudids) - df.loc[self.inscrits_ids, "nb_inscrits"] = len(self.inscrits_ids) - - # Le classement des inscrits - notes_non_nulles = notes[self.inscrits_ids] - (class_str, class_int) = comp_ranks_series(notes_non_nulles) - df.loc[self.inscrits_ids, "classement"] = class_int - - # Le rang (classement/nb_inscrit) - df["rang"] = df["rang"].astype(str) - df.loc[self.inscrits_ids, "rang"] = ( - df.loc[self.inscrits_ids, "classement"].astype(int).astype(str) - + "/" - + df.loc[self.inscrits_ids, "nb_inscrits"].astype(int).astype(str) - ) - - # Les stat (des inscrits) - df.loc[self.inscrits_ids, "min"] = notes.min() - df.loc[self.inscrits_ids, "max"] = notes.max() - df.loc[self.inscrits_ids, "moy"] = notes.mean() - - return df - - def to_dict(self) -> dict: - """Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques""" - synthese = { - "notes": self.df["note"], - "classements": self.df["classement"], - "min": self.df["min"].mean(), - "max": self.df["max"].mean(), - "moy": self.df["moy"].mean(), - "nb_inscrits": self.df["nb_inscrits"].mean(), - } - return synthese - - def get_notes(self): - """Série des notes, arrondies à 2 chiffres après la virgule""" - return self.df["note"].round(2) - - def get_rangs_inscrits(self) -> pd.Series: - """Série des rangs classement/nbre_inscrit""" - return self.df["rang"] - - def get_min(self) -> pd.Series: - """Série des min""" - return self.df["min"].round(2) - - def get_max(self) -> pd.Series: - """Série des max""" - return self.df["max"].round(2) - - def get_moy(self) -> pd.Series: - """Série des moy""" - return self.df["moy"].round(2) - - - def get_note_for_df(self, etudid: int): - """Note d'un étudiant donné par son etudid""" - return round(self.df["note"].loc[etudid], 2) - - def get_min_for_df(self) -> float: - """Min renseigné pour affichage dans un df""" - return round(self.synthese["min"], 2) - - def get_max_for_df(self) -> float: - """Max renseigné pour affichage dans un df""" - return round(self.synthese["max"], 2) - - def get_moy_for_df(self) -> float: - """Moyenne renseignée pour affichage dans un df""" - return round(self.synthese["moy"], 2) - - def get_class_for_df(self, etudid: int) -> str: - """Classement ramené au nombre d'inscrits, - pour un étudiant donné par son etudid""" - classement = self.df["rang"].loc[etudid] - if not pd.isna(classement): - return classement - else: - return pe_affichage.SANS_NOTE - - def is_significatif(self) -> bool: - """Indique si la moyenne est significative (c'est-à-dire à des notes)""" - return self.synthese["nb_inscrits"] > 0 - - -class TableTag(object): - def __init__(self): - """Classe centralisant différentes méthodes communes aux - SemestreTag, TrajectoireTag, AggregatInterclassTag - """ - pass - - # ----------------------------------------------------------------------------------------------------------- - def get_all_tags(self): - """Liste des tags de la table, triée par ordre alphabétique, - extraite des clés du dictionnaire ``moyennes_tags`` connues (tags en doublon - possible). - - Returns: - Liste de tags triés par ordre alphabétique - """ - return sorted(list(self.moyennes_tags.keys())) - - def df_moyennes_et_classements(self) -> pd.DataFrame: - """Renvoie un dataframe listant toutes les moyennes, - et les classements des étudiants pour tous les tags. - - Est utilisé pour afficher le détail d'un tableau taggué - (semestres, trajectoires ou aggrégat) - - Returns: - Le dataframe des notes et des classements - """ - - etudiants = self.etudiants - df = pd.DataFrame.from_dict(etudiants, orient="index", columns=["nom"]) - - tags_tries = self.get_all_tags() - for tag in tags_tries: - moy_tag = self.moyennes_tags[tag] - df = df.join(moy_tag.synthese["notes"].rename(f"Moy {tag}")) - df = df.join(moy_tag.synthese["classements"].rename(f"Class {tag}")) - - return df - - def df_notes(self) -> pd.DataFrame | None: - """Renvoie un dataframe (etudid x tag) listant toutes les moyennes par tags - - Returns: - Un dataframe etudids x tag (avec tag par ordre alphabétique) - """ - tags_tries = self.get_all_tags() - if tags_tries: - dict_series = {} - for tag in tags_tries: - # Les moyennes associés au tag - moy_tag = self.moyennes_tags[tag] - dict_series[tag] = moy_tag.synthese["notes"] - df = pd.DataFrame(dict_series) - return df diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index 8ef4fe444..2add21ba8 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -38,6 +38,7 @@ from flask import flash, g, redirect, render_template, request, send_file, url_for from app.decorators import permission_required, scodoc +from app.forms.pe.pe_sem_recap import ParametrageClasseurPE from app.models import FormSemestre from app.pe import pe_comp from app.pe import pe_jury @@ -73,32 +74,50 @@ def pe_view_sem_recap(formsemestre_id: int): # Cosemestres diplomants cosemestres = pe_comp.get_cosemestres_diplomants(annee_diplome) + form = ParametrageClasseurPE() + + cosemestres_tries = pe_comp.tri_semestres_par_rang(cosemestres) + affichage_cosemestres_tries = { + rang: ", ".join([sem.titre_annee() for sem in cosemestres_tries[rang]]) + for rang in cosemestres_tries + } if request.method == "GET": return render_template( "pe/pe_view_sem_recap.j2", annee_diplome=annee_diplome, + form=form, formsemestre=formsemestre, sco=ScoData(formsemestre=formsemestre), - cosemestres=cosemestres, + cosemestres=affichage_cosemestres_tries, + rangs_tries=sorted(affichage_cosemestres_tries.keys()), ) # request.method == "POST" - jury = pe_jury.JuryPE(annee_diplome) - if not jury.diplomes_ids: - flash("aucun étudiant à considérer !") - return redirect( - url_for( - "notes.pe_view_sem_recap", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, + if form.validate_on_submit(): + jury = pe_jury.JuryPE(annee_diplome, formsemestre_id, options=form.data) + if not jury.diplomes_ids: + flash("aucun étudiant à considérer !") + return redirect( + url_for( + "notes.pe_view_sem_recap", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) ) + + data = jury.get_zipped_data() + + return send_file( + data, + mimetype="application/zip", + download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"), + as_attachment=True, ) - data = jury.get_zipped_data() - - return send_file( - data, - mimetype="application/zip", - download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"), - as_attachment=True, + return redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) ) diff --git a/app/pe/rcss/__init__.py b/app/pe/rcss/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/pe/rcss/pe_rcs.py b/app/pe/rcss/pe_rcs.py new file mode 100644 index 000000000..c7737153d --- /dev/null +++ b/app/pe/rcss/pe_rcs.py @@ -0,0 +1,131 @@ +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +""" +Created on 01-2024 + +@author: barasc +""" + +from app.models import FormSemestre + +TYPES_RCS = { + "S1": { + "aggregat": ["S1"], + "descr": "Semestre 1 (S1)", + }, + "S2": { + "aggregat": ["S2"], + "descr": "Semestre 2 (S2)", + }, + "1A": { + "aggregat": ["S1", "S2"], + "descr": "BUT1 (S1+S2)", + }, + "S3": { + "aggregat": ["S3"], + "descr": "Semestre 3 (S3)", + }, + "S4": { + "aggregat": ["S4"], + "descr": "Semestre 4 (S4)", + }, + "2A": { + "aggregat": ["S3", "S4"], + "descr": "BUT2 (S3+S4)", + }, + "3S": { + "aggregat": ["S1", "S2", "S3"], + "descr": "Moyenne du S1 au S3 (S1+S2+S3)", + }, + "4S": { + "aggregat": ["S1", "S2", "S3", "S4"], + "descr": "Moyenne du S1 au S4 (S1+S2+S3+S4)", + }, + "S5": { + "aggregat": ["S5"], + "descr": "Semestre 5 (S5)", + }, + "S6": { + "aggregat": ["S6"], + "descr": "Semestre 6 (S6)", + }, + "3A": { + "aggregat": ["S5", "S6"], + "descr": "BUT3 (S5+S6)", + }, + "5S": { + "aggregat": ["S1", "S2", "S3", "S4", "S5"], + "descr": "Moyenne du S1 au S5 (S1+S2+S3+S4+S5)", + }, + "6S": { + "aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"], + "descr": "Moyenne globale (S1+S2+S3+S4+S5+S6)", + }, +} +"""Dictionnaire détaillant les différents regroupements cohérents +de semestres (RCS), en leur attribuant un nom et en détaillant +le nom des semestres qu'ils regroupent et l'affichage qui en sera fait +dans les tableurs de synthèse. +""" + +TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS if not cle.startswith("S")] +TOUS_LES_RCS = list(TYPES_RCS.keys()) +TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS if cle.startswith("S")] + + +def get_descr_rcs(nom_rcs: str) -> str: + """Renvoie la description pour les tableurs de synthèse + Excel d'un nom de RCS""" + return TYPES_RCS[nom_rcs]["descr"] + + +class RCS: + """Modélise un regroupement cohérent de semestres, + tous se terminant par un (form)semestre final. + """ + + def __init__(self, nom: str, semestre_final: FormSemestre): + self.nom: str = nom + """Nom du RCS""" + assert self.nom in TOUS_LES_RCS, "Le nom d'un RCS doit être un aggrégat" + + self.aggregat: list[str] = TYPES_RCS[nom]["aggregat"] + """Aggrégat (liste des nom des semestres aggrégés)""" + + self.formsemestre_final: FormSemestre = semestre_final + """(Form)Semestre final du RCS""" + + self.rang_final = self.formsemestre_final.semestre_id + """Rang du formsemestre final""" + + self.rcs_id: (str, int) = (nom, semestre_final.formsemestre_id) + """Identifiant du RCS sous forme (nom_rcs, id du semestre_terminal)""" + + self.fid_final: int = self.formsemestre_final.formsemestre_id + """Identifiant du (Form)Semestre final""" + + def get_formsemestre_id_final(self) -> int: + """Renvoie l'identifiant du formsemestre final du RCS + + Returns: + L'id du formsemestre final (marquant la fin) du RCS + """ + return self.formsemestre_final.formsemestre_id + + def __str__(self): + """Représentation textuelle d'un RCS""" + return f"{self.nom}[#{self.formsemestre_final.formsemestre_id}✟{self.formsemestre_final.date_fin.year}]" + + def get_repr(self, verbose=True): + """Représentation textuelle d'un RCS""" + return self.__str__() + + def __eq__(self, other): + """Egalité de RCS""" + return ( + self.nom == other.nom + and self.formsemestre_final == other.formsemestre_final + ) diff --git a/app/pe/rcss/pe_rcsemx.py b/app/pe/rcss/pe_rcsemx.py new file mode 100644 index 000000000..e36596fbb --- /dev/null +++ b/app/pe/rcss/pe_rcsemx.py @@ -0,0 +1,59 @@ +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +""" +Created on 01-2024 + +@author: barasc +""" + +from app.models import FormSemestre +from app.pe.moys import pe_sxtag +from app.pe.rcss import pe_rcs, pe_trajectoires + + +class RCSemX(pe_rcs.RCS): + """Modélise un regroupement cohérent de SemX (en même regroupant + des semestres Sx combinés pour former les résultats des étudiants + au semestre de rang x) dans le but de synthétiser les résultats + du S1 jusqu'au semestre final ciblé par le RCSemX (dépendant de l'aggrégat + visé). + + Par ex: Si l'aggrégat du RCSemX est '3S' (=S1+S2+S3), + regroupement le SemX du S1 + le SemX du S2 + le SemX du S3 (chacun + incluant des infos sur les redoublements). + + Args: + nom: Un nom du RCS (par ex: '5S') + semestre_final: Le semestre final du RCS + """ + + def __init__(self, nom: str, semestre_final: FormSemestre): + pe_rcs.RCS.__init__(self, nom, semestre_final) + + self.semXs_aggreges: dict[(str, int) : pe_sxtag.SxTag] = {} + """Les semX à aggréger""" + + def add_semXs(self, semXs: dict[(str, int) : pe_trajectoires.SemX]): + """Ajoute des semXs aux semXs à regrouper dans le RCSemX + + Args: + semXs: Dictionnaire ``{(str,fid): RCF}`` à ajouter + """ + self.semXs_aggreges = self.semXs_aggreges | semXs + + def get_repr(self, verbose=True) -> str: + """Représentation textuelle d'un RCSF + basé sur ses RCF aggrégés""" + title = f"""{self.__class__.__name__} {pe_rcs.RCS.__str__(self)}""" + if verbose: + noms = [] + for semx_id, semx in self.semXs_aggreges.items(): + noms.append(semx.get_repr(verbose=False)) + if noms: + title += " <<" + "+".join(noms) + ">>" + else: + title += " <>" + return title diff --git a/app/pe/rcss/pe_trajectoires.py b/app/pe/rcss/pe_trajectoires.py new file mode 100644 index 000000000..4e1a1d1ec --- /dev/null +++ b/app/pe/rcss/pe_trajectoires.py @@ -0,0 +1,87 @@ +from app.models import FormSemestre +import app.pe.rcss.pe_rcs as pe_rcs + + +class Trajectoire(pe_rcs.RCS): + """Regroupement Cohérent de Semestres ciblant un type d'aggrégat (par ex. + 'S2', '3S', '1A') et un semestre final, et dont les données regroupées + sont des **FormSemestres** suivis par les étudiants. + + Une *Trajectoire* traduit la succession de semestres + qu'ont pu suivre des étudiants pour aller d'un semestre S1 jusqu'au semestre final + de l'aggrégat. + + Une *Trajectoire* peut être : + + * un RCS de semestre de type "Sx" (cf. classe "SemX"), qui stocke les + formsemestres de rang x qu'ont suivi l'étudiant pour valider le Sx + (en général 1 formsemestre pour les non-redoublants et 2 pour les redoublants) + + * un RCS de type iS ou iA (par ex, 3A=S1+S2+S3), qui identifie + les formsemestres que des étudiants ont suivis pour les amener jusqu'au semestre + terminal du RCS. Par ex: si le RCS est un 3S: + + * des S1+S2+S1+S2+S3 si redoublement de la 1ère année + * des S1+S2+(année de césure)+S3 si césure, ... + + Args: + nom: Un nom du RCS (par ex: '5S') + semestre_final: Le formsemestre final du RCS + """ + + def __init__(self, nom: str, semestre_final: FormSemestre): + pe_rcs.RCS.__init__(self, nom, semestre_final) + + self.semestres_aggreges: dict[int:FormSemestre] = {} + """Formsemestres regroupés dans le RCS""" + + def add_semestres(self, semestres: dict[int:FormSemestre]): + """Ajout de semestres aux semestres à regrouper + + Args: + semestres: Dictionnaire ``{fid: Formsemestre)`` + """ + for sem in semestres.values(): + assert isinstance( + sem, FormSemestre + ), "Les données aggrégées d'une Trajectoire doivent être des FormSemestres" + self.semestres_aggreges = self.semestres_aggreges | semestres + + def get_repr(self, verbose=True) -> str: + """Représentation textuelle d'un RCS + basé sur ses semestres aggrégés""" + title = f"""{self.__class__.__name__} {pe_rcs.RCS.__str__(self)}""" + if verbose: + noms = [] + for fid in self.semestres_aggreges: + semestre = self.semestres_aggreges[fid] + noms.append(f"S{semestre.semestre_id}#{fid}") + noms = sorted(noms) + if noms: + title += " <" + "+".join(noms) + ">" + else: + title += " " + return title + + +class SemX(Trajectoire): + """Trajectoire (regroupement cohérent de (form)semestres + dans laquelle tous les semestres regroupés sont de même rang `x`. + + Les SemX stocke les + formsemestres de rang x qu'ont suivi l'étudiant pour valider le Sx + (en général 1 formsemestre pour les non-redoublants et 2 pour les redoublants). + + Ils servent à calculer les SemXTag (moyennes par tag des RCS de type `Sx`). + """ + + def __init__(self, trajectoire: Trajectoire): + Trajectoire.__init__(self, trajectoire.nom, trajectoire.formsemestre_final) + + semestres_aggreges = trajectoire.semestres_aggreges + for sem in semestres_aggreges.values(): + assert ( + sem.semestre_id == trajectoire.rang_final + ), "Tous les semestres aggrégés d'un SemX doivent être de même rang" + + self.semestres_aggreges = trajectoire.semestres_aggreges diff --git a/app/templates/pe/pe_view_resultats_etudiant.j2 b/app/templates/pe/pe_view_resultats_etudiant.j2 new file mode 100644 index 000000000..63cbc1e22 --- /dev/null +++ b/app/templates/pe/pe_view_resultats_etudiant.j2 @@ -0,0 +1,76 @@ + + + + + + PE de {{ nom }} + + + + + +
    + +

    Résultats PE de {{prenom}} {{nom}} ({{ parcours }})

    + + + +

    Légende

    +
      +
    • ../.. Classement par groupe
    • +
    • ../.. Classement par promo
    • +
    + +{% for tag in tags %} + +

    Tag 👜 {{ tag }}

    + + + + + + + {% for col in colonnes_html %} + + {% endfor %} + + + + {% for col in colonnes_html %} + + {% endfor %} + + + + + {% for aggregat in moyennes[tag] %} + + + {% for comp in moyennes[tag][aggregat] %} + + + {% endfor %} + + {% endfor %} + +
    {{ col }}Général
    NoteClass.NoteClass.
    {{ aggregat }}{{ moyennes[tag][aggregat][comp]["note"] }}{{ moyennes[tag][aggregat][comp]["rang_groupe"] }} + {{ moyennes[tag][aggregat][comp]["rang_promo"] }}
    +{% endfor %} + +
    main> + + \ No newline at end of file diff --git a/app/templates/pe/pe_view_sem_recap.j2 b/app/templates/pe/pe_view_sem_recap.j2 index 756b7f870..0ef4f7cc7 100644 --- a/app/templates/pe/pe_view_sem_recap.j2 +++ b/app/templates/pe/pe_view_sem_recap.j2 @@ -1,4 +1,5 @@ {% extends "sco_page.j2" %} +{% import 'wtf.j2' as wtf %} {% block styles %} {{super()}} @@ -30,7 +31,7 @@

    Cette fonction génère un ensemble de feuilles de calcul (xlsx) permettant d'éditer des avis de poursuites d'études pour les étudiants - de BUT diplômés. + de BUT diplômés. Les calculs sous-jacents peuvent prendre un peu de temps (1 à 3 minutes).
    De nombreux aspects sont paramétrables: Avis de poursuites d'études de la promo {{ annee_diplome }} -

    -
    - -
    - -
    +

    Options

    + {{ wtf.quick_form(form) }} - - {% endblock app_content %} \ No newline at end of file diff --git a/config.py b/config.py index d98e95138..eefebdfc1 100755 --- a/config.py +++ b/config.py @@ -61,11 +61,11 @@ class DevConfig(Config): DEBUG = True TESTING = False SQLALCHEMY_DATABASE_URI = ( - os.environ.get("SCODOC_DATABASE_URI") or "postgresql:///SCODOC_DEV" + os.environ.get("SCODOC_DATABASE_URI") or "postgresql:///SCODOC" ) SECRET_KEY = os.environ.get("DEV_SECRET_KEY") or "bb3faec7d9a34eb68a8e3e710087d87a" # pour le avoir url_for dans le shell: - # SERVER_NAME = os.environ.get("SCODOC_TEST_SERVER_NAME") or "localhost" + SERVER_NAME = "http://localhost:8080" class TestConfig(DevConfig):