diff --git a/app/pe/pe_affichage.py b/app/pe/pe_affichage.py new file mode 100644 index 0000000000000000000000000000000000000000..50f2e2ab71d8a166ee0f1c30a8487a23b4962854 --- /dev/null +++ b/app/pe/pe_affichage.py @@ -0,0 +1,44 @@ +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +"""Affichages, debug +""" + +from flask import g +from app import log + +PE_DEBUG = False + + +# On stocke les logs PE dans g.scodoc_pe_log +# pour ne pas modifier les nombreux appels à pe_print. +def pe_start_log() -> list[str]: + "Initialize log" + g.scodoc_pe_log = [] + return g.scodoc_pe_log + + +def pe_print(*a): + "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: + print(msg) + else: + log(msg) + + +def pe_get_log() -> str: + "Renvoie une chaîne avec tous les messages loggués" + return "\n".join(getattr(g, "scodoc_pe_log", [])) + + +# 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" diff --git a/app/pe/pe_comp.py b/app/pe/pe_comp.py new file mode 100644 index 0000000000000000000000000000000000000000..67805dfa49e1f129303725f17cc8a50634cf6e64 --- /dev/null +++ b/app/pe/pe_comp.py @@ -0,0 +1,286 @@ +# -*- 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 os +import datetime +import re +import unicodedata + + +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.scodoc import sco_formsemestre +from app.scodoc.sco_logos import find_logo + + +# Generated LaTeX files are encoded as: +PE_LATEX_ENCODING = "utf-8" + +# /opt/scodoc/tools/doc_poursuites_etudes +REP_DEFAULT_AVIS = os.path.join(scu.SCO_TOOLS_DIR, "doc_poursuites_etudes/") +REP_LOCAL_AVIS = os.path.join(scu.SCODOC_CFG_DIR, "doc_poursuites_etudes/") + +PE_DEFAULT_AVIS_LATEX_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_avis.tex" +PE_LOCAL_AVIS_LATEX_TMPL = REP_LOCAL_AVIS + "local/modeles/un_avis.tex" +PE_DEFAULT_FOOTER_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_footer.tex" +PE_LOCAL_FOOTER_TMPL = REP_LOCAL_AVIS + "local/modeles/un_footer.tex" + +# ---------------------------------------------------------------------------------------- + +""" +Descriptif d'un parcours classique BUT + +TODO:: A améliorer si BUT en moins de 6 semestres +""" + +NBRE_SEMESTRES_DIPLOMANT = 6 +AGGREGAT_DIPLOMANT = ( + "6S" # aggrégat correspondant à la totalité des notes pour le diplôme +) +TOUS_LES_SEMESTRES = TYPES_RCS[AGGREGAT_DIPLOMANT]["aggregat"] + + +# ---------------------------------------------------------------------------------------- +def calcul_age(born: datetime.date) -> int: + """Calcule l'age connaissant la date de naissance ``born``. (L'age est calculé + à partir de l'horloge système). + + Args: + born: La date de naissance + + Return: + L'age (au regard de la date actuelle) + """ + if not born or not isinstance(born, datetime.date): + return None + + today = datetime.date.today() + return today.year - born.year - ((today.month, today.day) < (born.month, born.day)) + + +# Nota: scu.suppress_accents fait la même chose mais renvoie un str et non un bytes +def remove_accents(input_unicode_str: str) -> bytes: + """Supprime les accents d'une chaine unicode""" + nfkd_form = unicodedata.normalize("NFKD", input_unicode_str) + only_ascii = nfkd_form.encode("ASCII", "ignore") + return only_ascii + + +def escape_for_latex(s): + """Protège les caractères pour inclusion dans du source LaTeX""" + if not s: + return "" + conv = { + "&": r"\&", + "%": r"\%", + "$": r"\$", + "#": r"\#", + "_": r"\_", + "{": r"\{", + "}": r"\}", + "~": r"\textasciitilde{}", + "^": r"\^{}", + "\\": r"\textbackslash{}", + "<": r"\textless ", + ">": r"\textgreater ", + } + exp = re.compile( + "|".join( + re.escape(key) + for key in sorted(list(conv.keys()), key=lambda item: -len(item)) + ) + ) + return exp.sub(lambda match: conv[match.group()], s) + + +# ---------------------------------------------------------------------------------------- +def list_directory_filenames(path: str) -> list[str]: + """List of regular filenames (paths) in a directory (recursive) + Excludes files and directories begining with . + """ + paths = [] + for root, dirs, files in os.walk(path, topdown=True): + dirs[:] = [d for d in dirs if d[0] != "."] + paths += [os.path.join(root, fn) for fn in files if fn[0] != "."] + return paths + + +def add_local_file_to_zip(zipfile, ziproot, pathname, path_in_zip): + """Read pathname server file and add content to zip under path_in_zip""" + rooted_path_in_zip = os.path.join(ziproot, path_in_zip) + zipfile.write(filename=pathname, arcname=rooted_path_in_zip) + # data = open(pathname).read() + # zipfile.writestr(rooted_path_in_zip, data) + + +def add_refs_to_register(register, directory): + """Ajoute les fichiers trouvés dans directory au registre (dictionaire) sous la forme + filename => pathname + """ + length = len(directory) + for pathname in list_directory_filenames(directory): + filename = pathname[length + 1 :] + register[filename] = pathname + + +def add_pe_stuff_to_zip(zipfile, ziproot): + """Add auxiliary files to (already opened) zip + Put all local files found under config/doc_poursuites_etudes/local + and config/doc_poursuites_etudes/distrib + If a file is present in both subtrees, take the one in local. + + Also copy logos + """ + register = {} + # first add standard (distrib references) + distrib_dir = os.path.join(REP_DEFAULT_AVIS, "distrib") + add_refs_to_register(register=register, directory=distrib_dir) + # then add local references (some oh them may overwrite distrib refs) + local_dir = os.path.join(REP_LOCAL_AVIS, "local") + add_refs_to_register(register=register, directory=local_dir) + # at this point register contains all refs (filename, pathname) to be saved + for filename, pathname in register.items(): + add_local_file_to_zip(zipfile, ziproot, pathname, "avis/" + filename) + + # Logos: (add to logos/ directory in zip) + logos_names = ["header", "footer"] + for name in logos_names: + logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id) + if logo is not None: + add_local_file_to_zip( + zipfile, ziproot, logo.filepath, "avis/logos/" + logo.filename + ) + + +# ---------------------------------------------------------------------------------------- +def get_annee_diplome_semestre( + sem_base: FormSemestre | dict, nbre_sem_formation: int = 6 +) -> int: + """Pour un semestre ``sem_base`` donné (supposé être un semestre d'une formation BUT + à 6 semestres) et connaissant le numéro du semestre, ses dates de début et de fin du + semestre, prédit l'année à laquelle sera remis le diplôme BUT des étudiants qui y + sont scolarisés (en supposant qu'il n'y ait pas de redoublement à venir). + + **Remarque sur le calcul** : Les semestres de 1ère partie d'année (S1, S3, S5 ou S4, + S6 pour des semestres décalés) + s'étalent sur deux années civiles ; contrairement au semestre de seconde partie + d'année universitaire. + + Par exemple : + + * S5 débutant en 2025 finissant en 2026 : diplome en 2026 + * S3 debutant en 2025 et finissant en 2026 : diplome en 2027 + + La fonction est adaptée au cas des semestres décalés. + + Par exemple : + + * S5 décalé débutant en 2025 et finissant en 2025 : diplome en 2026 + * S3 décalé débutant en 2025 et finissant en 2025 : diplome en 2027 + + Args: + sem_base: Le semestre à partir duquel est prédit l'année de diplomation, soit : + + * un ``FormSemestre`` (Scodoc9) + * un dict (format compatible avec Scodoc7) + + nbre_sem_formation: Le nombre de semestre prévu dans la formation (par défaut 6 pour un BUT) + """ + + if isinstance(sem_base, FormSemestre): + sem_id = sem_base.semestre_id + annee_fin = sem_base.date_fin.year + annee_debut = sem_base.date_debut.year + else: # sem_base est un dictionnaire (Scodoc 7) + sem_id = sem_base["semestre_id"] + annee_fin = int(sem_base["annee_fin"]) + annee_debut = int(sem_base["annee_debut"]) + if ( + 1 <= sem_id <= nbre_sem_formation + ): # Si le semestre est un semestre BUT => problème si formation BUT en 1 an ?? + nb_sem_restants = ( + nbre_sem_formation - sem_id + ) # nombre de semestres restant avant diplome + nb_annees_restantes = ( + nb_sem_restants // 2 + ) # nombre d'annees restant avant diplome + # Flag permettant d'activer ou désactiver un increment + # à prendre en compte en cas de semestre décalé + # avec 1 - delta = 0 si semestre de 1ere partie d'année / 1 sinon + delta = annee_fin - annee_debut + decalage = nb_sem_restants % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1 + increment = decalage * (1 - delta) + return annee_fin + nb_annees_restantes + increment + + +def get_cosemestres_diplomants(annee_diplome: int) -> dict[int, FormSemestre]: + """Ensemble des cosemestres donnant lieu à diplomation à l'``annee_diplome``. + + **Définition** : Un co-semestre est un semestre : + + * dont l'année de diplômation prédite (sans redoublement) est la même + * dont la formation est la même (optionnel) + * qui a des étudiants inscrits + + Args: + annee_diplome: L'année de diplomation + + Returns: + Un dictionnaire {fid: FormSemestre(fid)} contenant les cosemestres + """ + tous_les_sems = ( + sco_formsemestre.do_formsemestre_list() + ) # tous les semestres memorisés dans scodoc + + cosemestres_fids = { + sem["id"] + for sem in tous_les_sems + if get_annee_diplome_semestre(sem) == annee_diplome + } + + cosemestres = {} + for fid in cosemestres_fids: + cosem = FormSemestre.get_formsemestre(fid) + if len(cosem.etuds_inscriptions) > 0: + cosemestres[fid] = cosem + + return cosemestres diff --git a/app/pe/pe_etudiant.py b/app/pe/pe_etudiant.py new file mode 100644 index 0000000000000000000000000000000000000000..cdf6a5ecd3b5fe55df5ef57e4e68784aa0171306 --- /dev/null +++ b/app/pe/pe_etudiant.py @@ -0,0 +1,619 @@ +# -*- 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 17/01/2024 + +@author: barasc +""" +import pandas as pd + +from app.models import FormSemestre, Identite, Formation +from app.pe import pe_comp, pe_affichage +from app.scodoc import codes_cursus +from app.scodoc import sco_utils as scu +from app.comp.res_sem import load_formsemestre_results + + +class EtudiantsJuryPE: + """Classe centralisant la gestion des étudiants à prendre en compte dans un jury de PE""" + + def __init__(self, annee_diplome: int): + """ + Args: + annee_diplome: L'année de diplomation + """ + 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.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.etudiants_diplomes = {} + """Les identités des étudiants à considérer au jury (ceux qui seront effectivement + diplômés)""" + + self.diplomes_ids = {} + """Les etudids des étudiants diplômés""" + + self.etudiants_ids = {} + """Les etudids des étudiants dont il faut calculer les moyennes/classements + (même si d'éventuels abandons). + Il s'agit des étudiants inscrits dans les co-semestres (ceux du jury mais aussi + d'autres ayant été réorientés ou ayant abandonnés)""" + + self.cosemestres: dict[int, FormSemestre] = None + "Les cosemestres donnant lieu à même année de diplome" + + self.abandons = {} + """Les étudiants qui ne seront pas diplômés à ce jury (redoublants/réorientés)""" + self.abandons_ids = {} + """Les etudids des étudiants redoublants/réorientés""" + + def find_etudiants(self): + """Liste des étudiants à prendre en compte dans le jury PE, en les recherchant + de manière automatique par rapport à leur année de diplomation ``annee_diplome``. + + Les données obtenues sont stockées dans les attributs de EtudiantsJuryPE. + + *Remarque* : ex: JuryPE.get_etudiants_in_jury() + """ + cosemestres = pe_comp.get_cosemestres_diplomants(self.annee_diplome) + self.cosemestres = cosemestres + + pe_affichage.pe_print( + f"1) Recherche des coSemestres -> {len(cosemestres)} trouvés" + ) + + 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" + ) + + # 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") + + 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) + + # Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris + self.etudiants_diplomes = self.get_etudiants_diplomes() + self.diplomes_ids = set(self.etudiants_diplomes.keys()) + self.etudiants_ids = set(self.identites.keys()) + + # Les abandons (pour debug) + self.abandons = self.get_etudiants_redoublants_ou_reorientes() + # Les identités des étudiants ayant redoublés ou ayant abandonnés + + self.abandons_ids = set(self.abandons) + # Les identifiants des étudiants ayant redoublés ou ayant abandonnés + + # Synthèse + pe_affichage.pe_print( + f" => {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}" + ) + 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" + ) + # 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 get_etudiants_diplomes(self) -> dict[int, Identite]: + """Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}` + qui vont être à traiter au jury PE pour + l'année de diplômation donnée et n'ayant ni été réorienté, ni abandonné. + + + Returns: + Un dictionnaire `{etudid: Identite(etudid)}` + """ + etudids = [ + etudid + for etudid, cursus_etud in self.cursus.items() + if cursus_etud["diplome"] == self.annee_diplome + and cursus_etud["abandon"] is False + ] + etudiants = {etudid: self.identites[etudid] for etudid in etudids} + return etudiants + + def get_etudiants_redoublants_ou_reorientes(self) -> dict[int, Identite]: + """Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}` + dont les notes seront prises en compte (pour les classements) mais qui n'apparaitront + pas dans le jury car diplômé une autre année (redoublants) ou réorienté ou démissionnaire. + + Returns: + Un dictionnaire `{etudid: Identite(etudid)}` + """ + etudids = [ + etudid + for etudid, cursus_etud in self.cursus.items() + if cursus_etud["diplome"] != self.annee_diplome + or cursus_etud["abandon"] is True + ] + etudiants = {etudid: self.identites[etudid] for etudid in etudids} + return etudiants + + def analyse_etat_etudiant(self, etudid: int, cosemestres: dict[int, FormSemestre]): + """Analyse le cursus d'un étudiant pouvant être : + + * l'un de ceux sur lesquels le jury va statuer (année de diplômation du jury considéré) + * un étudiant qui ne sera pas considéré dans le jury mais qui a participé dans sa scolarité + à un (ou plusieurs) semestres communs aux étudiants du jury (et impactera les classements) + + L'analyse consiste : + + * à 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) + + Args: + etudid: L'etudid d'un étudiant, à ajouter à ceux traiter par le jury + cosemestres: Dictionnaire {fid: Formsemestre(fid)} donnant accès aux cosemestres + de même année de diplomation + """ + identite = Identite.get_etud(etudid) + + # Le cursus global de l'étudiant (restreint aux semestres APC) + formsemestres = identite.get_formsemestres() + + semestres_etudiant = { + formsemestre.formsemestre_id: formsemestre + for formsemestre in formsemestres + if formsemestre.formation.is_apc() + } + + 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 + "diplome": get_annee_diplome( + identite + ), # Le date prévisionnelle de son diplôme + "formsemestres": semestres_etudiant, # les semestres de l'étudiant + "nb_semestres": len( + semestres_etudiant + ), # le nombre de semestres de l'étudiant + "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) + + 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 + + def structure_cursus_etudiant(self, etudid: int): + """Structure les informations sur les semestres suivis par un + étudiant, pour identifier les semestres qui seront pris en compte lors de ses calculs + de moyennes PE. + + 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. + """ + semestres_significatifs = self.get_semestres_significatifs(etudid) + + # Tri des semestres par numéro de semestre + for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1): + # les semestres de n°i de l'étudiant: + semestres_i = { + fid: sem_sig + for fid, sem_sig in semestres_significatifs.items() + if sem_sig.semestre_id == i + } + 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 : + + * 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 + formsemestre_id du S3 FI et du S3 UFA. + * les éventuelles situations de redoublement (par ex pour 1 étudiant ayant + redoublé sa 2ème année : + S1 + S2 + S3 (1ère session) et S1 + S2 + S3 + S4 + S3 (2ème session), en + renvoyant les formsemestre_id du S3 (1ère session) et du S3 (2ème session) + + Args: + aggregat: L'aggrégat + + Returns: + Un dictionnaire ``{fid: FormSemestre(fid)}`` + """ + formsemestres_terminaux = {} + for trajectoire_aggr in self.trajectoires.values(): + trajectoire = trajectoire_aggr[aggregat] + if trajectoire: + # Le semestre terminal de l'étudiant de l'aggrégat + fid = trajectoire.formsemestre_final.formsemestre_id + formsemestres_terminaux[fid] = trajectoire.formsemestre_final + return formsemestres_terminaux + + def nbre_etapes_max_diplomes(self, etudids: list[int]) -> int: + """Partant d'un ensemble d'étudiants, + nombre de semestres (étapes) maximum suivis par les étudiants du jury. + + Args: + etudids: Liste d'étudid d'étudiants + """ + nbres_semestres = [] + for etudid in etudids: + nbres_semestres.append(self.cursus[etudid]["nb_semestres"]) + if not nbres_semestres: + return 0 + return max(nbres_semestres) + + def df_administratif(self, etudids: list[int]) -> pd.DataFrame: + """Synthétise toutes les données administratives d'un groupe + d'étudiants fournis par les etudid dans un dataFrame + + Args: + etudids: La liste des étudiants à prendre en compte + """ + + etudids = list(etudids) + + # Récupération des données des étudiants + administratif = {} + nbre_semestres_max = self.nbre_etapes_max_diplomes(etudids) + + for etudid in etudids: + etudiant = self.identites[etudid] + cursus = self.cursus[etudid] + formsemestres = cursus["formsemestres"] + + if cursus["diplome"]: + diplome = cursus["diplome"] + else: + diplome = "indéterminé" + + administratif[etudid] = { + "etudid": etudiant.id, + "INE": etudiant.code_ine or "", + "NIP": etudiant.code_nip or "", + "Nom": etudiant.nom, + "Prenom": etudiant.prenom, + "Civilite": etudiant.civilite_str, + "Age": pe_comp.calcul_age(etudiant.date_naissance), + "Date entree": cursus["entree"], + "Date diplome": diplome, + "Nb semestres": len(formsemestres), + } + + # Ajout des noms de semestres parcourus + etapes = etapes_du_cursus(formsemestres, nbre_semestres_max) + administratif[etudid] |= etapes + + # Construction du dataframe + df = pd.DataFrame.from_dict(administratif, orient="index") + + # Tri par nom/prénom + df.sort_values(by=["Nom", "Prenom"], inplace=True) + return df + + +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 + + Returns: + Un ensemble d``etudid`` + """ + + etudiants_ids = set() + for sem in semestres.values(): # pour chacun des semestres de la liste + etudiants_du_sem = {ins.etudid for ins in sem.inscriptions} + + pe_affichage.pe_print(f" --> {sem} : {len(etudiants_du_sem)} etudiants") + etudiants_ids = ( + etudiants_ids | etudiants_du_sem + ) # incluant la suppression des doublons + + return etudiants_ids + + +def get_annee_diplome(etud: Identite) -> int | None: + """L'année de diplôme prévue d'un étudiant en fonction de ses semestres + d'inscription (pour un BUT). + + Args: + identite: L'identité d'un étudiant + + Returns: + L'année prévue de sa diplômation, ou None si aucun semestre + """ + formsemestres_apc = get_semestres_apc(etud) + + if formsemestres_apc: + dates_possibles_diplome = [] + # Années de diplômation prédites en fonction des semestres + # (d'une formation APC) d'un étudiant + for sem_base in formsemestres_apc: + annee = pe_comp.get_annee_diplome_semestre(sem_base) + if annee: + dates_possibles_diplome.append(annee) + if dates_possibles_diplome: + return max(dates_possibles_diplome) + + return None + + +def get_semestres_apc(identite: Identite) -> list: + """Liste des semestres d'un étudiant qui corresponde à une formation APC. + + Args: + identite: L'identité d'un étudiant + + Returns: + Liste de ``FormSemestre`` correspondant à une formation APC + """ + semestres = identite.get_formsemestres() + semestres_apc = [] + for sem in semestres: + if sem.formation.is_apc(): + semestres_apc.append(sem) + 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 : + + * 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 + des résultats du jury renseigné dans la BDD, mais pas nécessaire ici) + + * d'un arrêt volontaire : l'étudiant disparait des listes d'inscrits (sans pour + autant avoir été indiqué NAR ou DEM). + + 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. + + 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 + l'année visée. S'il n'est que dans un S4, il a sans doute arrêté. A moins qu'il ne soit + parti à l'étranger et là, pas de notes. + TODO:: Cas de l'étranger, à coder/tester + + **Attention** : Cela suppose que toutes les instances d'un semestre donné + (par ex: toutes les instances de S6 accueillant un étudiant soient créées ; sinon les + étudiants non inscrits dans un S6 seront considérés comme ayant abandonnés) + TODO:: Peut-être à mettre en regard avec les propositions d'inscriptions d'étudiants dans un nouveau semestre + + Pour chaque étudiant, recherche son dernier semestre en date (validé ou non) et + regarde s'il n'existe pas parmi les semestres existants dans Scodoc un semestre : + * dont les dates sont postérieures (en terme de date de début) + * de n° au moins égal à celui de son dernier semestre valide (S5 -> S5 ou S5 -> S6) + dans lequel il aurait pu s'inscrire mais ne l'a pas fait. + + Args: + etud: L'identité d'un étudiant + cosemestres: Les semestres donnant lieu à diplômation (sans redoublement) en date du jury + + Returns: + 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) + semestres_apc = {sem.semestre_id: sem for sem in semestres} + 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 + + # 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, + ) + ) + + # 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) + + 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 + + +def etapes_du_cursus( + semestres: dict[int, FormSemestre], nbre_etapes_max: int +) -> list[str]: + """Partant d'un dictionnaire de semestres (qui retrace + la scolarité d'un étudiant), liste les noms des + semestres (en version abbrégée) + qu'un étudiant a suivi au cours de sa scolarité à l'IUT. + Les noms des semestres sont renvoyés dans un dictionnaire + ``{"etape i": nom_semestre_a_etape_i}`` + avec i variant jusqu'à nbre_semestres_max. (S'il n'y a pas de semestre à l'étape i, + le nom affiché est vide. + + La fonction suppose la liste des semestres triées par ordre + décroissant de date. + + Args: + semestres: une liste de ``FormSemestre`` + nbre_etapes_max: le nombre d'étapes max prise en compte + + Returns: + Une liste de nom de semestre (dans le même ordre que les ``semestres``) + + See also: + app.pe.pe_affichage.nom_semestre_etape + """ + assert len(semestres) <= nbre_etapes_max + + noms = [nom_semestre_etape(sem, avec_fid=False) for (fid, sem) in semestres.items()] + noms = noms[::-1] # trie par ordre croissant + + dico = {f"Etape {i+1}": "" for i in range(nbre_etapes_max)} + for i, nom in enumerate(noms): # Charge les noms de semestres + dico[f"Etape {i+1}"] = nom + return dico + + +def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str: + """Nom d'un semestre à afficher dans le descriptif des étapes de la scolarité + d'un étudiant. + + Par ex: Pour un S2, affiche ``"Semestre 2 FI S014-2015 (129)"`` avec : + + * 2 le numéro du semestre, + * FI la modalité, + * 2014-2015 les dates + + Args: + semestre: Un ``FormSemestre`` + avec_fid: Ajoute le n° du semestre à la description + + Returns: + La chaine de caractères décrivant succintement le semestre + """ + formation: Formation = semestre.formation + parcours = codes_cursus.get_cursus_from_code(formation.type_parcours) + + description = [ + parcours.SESSION_NAME.capitalize(), + str(semestre.semestre_id), + semestre.modalite, # eg FI ou FC + f"{semestre.date_debut.year}-{semestre.date_fin.year}", + ] + if avec_fid: + description.append(f"({semestre.formsemestre_id})") + + return " ".join(description) diff --git a/app/pe/pe_interclasstag.py b/app/pe/pe_interclasstag.py new file mode 100644 index 0000000000000000000000000000000000000000..895595eddd3e594be5d8f1ee1b899961c0c35b45 --- /dev/null +++ b/app/pe/pe_interclasstag.py @@ -0,0 +1,160 @@ +############################################################################## +# +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..ac0c076f6e7ae6e5f9a74a0dfff66f07bf96ac06 --- /dev/null +++ b/app/pe/pe_jury.py @@ -0,0 +1,734 @@ +# -*- 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 +""" + +# ---------------------------------------------------------- +# Ensemble des fonctions et des classes +# permettant les calculs preliminaires (hors affichage) +# a l'edition d'un jury de poursuites d'etudes +# ---------------------------------------------------------- + +import io +import os +import time +from zipfile import ZipFile + +import numpy as np +import pandas as pd + +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 + + +class JuryPE(object): + """ + Classe mémorisant toutes les informations nécessaires pour établir un jury de PE, sur la base + d'une année de diplôme. De ce semestre est déduit : + 1. l'année d'obtention du DUT, + 2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés. + + 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): + pe_affichage.pe_start_log() + self.diplome = diplome + "L'année du diplome" + + self.nom_export_zip = f"Jury_PE_{self.diplome}" + "Nom du zip où ranger les fichiers générés" + + pe_affichage.pe_print( + f"Données de poursuite d'étude générées le {time.strftime('%d/%m/%Y à %H:%M')}\n" + ) + # 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}""" + ) + self.etudiants = EtudiantsJuryPE(self.diplome) # Les infos sur les étudiants + self.etudiants.find_etudiants() + self.diplomes_ids = self.etudiants.diplomes_ids + + self.zipdata = io.BytesIO() + with ZipFile(self.zipdata, "w") as zipfile: + if not self.diplomes_ids: + pe_affichage.pe_print("*** Aucun étudiant diplômé") + 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) + # et le log + self._add_log_to_zip(zipfile) + + # Fin !!!! Tada :) + + def _gen_xls_diplomes(self, zipfile: ZipFile): + "Intègre le bilan des semestres taggués au zip" + output = io.BytesIO() + with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated + output, engine="openpyxl" + ) as writer: + if self.diplomes_ids: + onglet = "diplômés" + df_diplome = self.etudiants.df_administratif(self.diplomes_ids) + df_diplome.to_excel(writer, onglet, index=True, header=True) + if self.etudiants.abandons_ids: + onglet = "redoublants-réorientés" + df_abandon = self.etudiants.df_administratif( + self.etudiants.abandons_ids + ) + df_abandon.to_excel(writer, onglet, index=True, header=True) + output.seek(0) + + self.add_file_to_zip( + zipfile, + f"etudiants_{self.diplome}.xlsx", + output.read(), + 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) + + # Intègre le bilan des semestres taggués au zip final + 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() + # écriture dans l'onglet + df.to_excel(writer, onglet, index=True, header=True) + output.seek(0) + + self.add_file_to_zip( + zipfile, + f"semestres_taggues_{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) + """ + 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 + ) + + # Intègre le bilan des trajectoires tagguées au zip final + 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) + output.seek(0) + + self.add_file_to_zip( + zipfile, + f"RCS_taggues_{self.diplome}.xlsx", + output.read(), + path="details", + ) + + 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 + ) + + # 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) + output.seek(0) + + self.add_file_to_zip( + zipfile, + f"interclassements_taggues_{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() + + # Export des données => mode 1 seule feuille -> supprimé + pe_affichage.pe_print("*** Export du jury de synthese par tags") + output = io.BytesIO() + with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated + output, engine="openpyxl" + ) as writer: + for onglet, df in self.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_tag.xlsx", output.read() + ) + + def _gen_xls_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() + + # 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() + ) + + def _add_log_to_zip(self, zipfile): + """Add a text file with the log messages""" + log_data = pe_affichage.pe_get_log() + self.add_file_to_zip(zipfile, "pe_log.txt", log_data) + + def add_file_to_zip(self, zipfile: ZipFile, filename: str, data, path=""): + """Add a file to given zip + All files under NOM_EXPORT_ZIP/ + path may specify a subdirectory + + Args: + zipfile: ZipFile + filename: Le nom du fichier à intégrer au zip + data: Les données du fichier + path: Un dossier dans l'arborescence du zip + """ + path_in_zip = os.path.join(path, filename) # self.nom_export_zip, + zipfile.writestr(path_in_zip, data) + + def get_zipped_data(self) -> io.BytesIO | None: + """returns file-like data with a zip of all generated (CSV) files. + Warning: reset stream to the begining. + """ + self.zipdata.seek(0) + return self.zipdata + + def do_tags_list(self, interclassements: dict[str, RCSInterclasseTag]): + """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 + + # **************************************************************************************************************** # + # 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, + tels que fourni dans l'excel final. + + Args: + tag: Un des tags (a minima `but`) + + 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") + + # Ajout des aggrégats + for aggregat in TOUS_LES_RCS: + descr = TYPES_RCS[aggregat]["descr"] + + # 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) + + # 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) + + # 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, + ], + ) + + for traj in trajectoires_tagguees: + # Les données des trajectoires_tagguees + moy_traj = traj.moyennes_tags[tag] + + # Les étudiants communs entre tableur de synthèse et trajectoires + inscrits_traj = moy_traj.inscrits_ids + etudids_communs = list(set(etudids) & set(inscrits_traj)) + + # 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_max() + 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 + + +def get_formsemestres_etudiants(etudiants: 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)}`` + contenant l'ensemble des formsemestres de leurs cursus, dont il faudra calculer + la moyenne. + + Args: + etudiants: Les étudiants du jury PE + + Returns: + Un dictionnaire de la forme `{fid: FormSemestre(fid)}` + + """ + semestres = {} + for etudid in etudiants.etudiants_ids: + for cle in etudiants.cursus[etudid]: + if cle.startswith("S"): + semestres = semestres | etudiants.cursus[etudid][cle] + 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é. + . + + Args: + etudiants: Un groupe d'étudiants participant au jury + + 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 diff --git a/app/pe/pe_rcs.py b/app/pe/pe_rcs.py new file mode 100644 index 0000000000000000000000000000000000000000..f9ec668836bf6d82530789c30e71a69e776a0c20 --- /dev/null +++ b/app/pe/pe_rcs.py @@ -0,0 +1,269 @@ +############################################################################## +# 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_rcstag.py b/app/pe/pe_rcstag.py new file mode 100644 index 0000000000000000000000000000000000000000..c3d3a05fd769b3788b1b76acabf51420cf9dcfd7 --- /dev/null +++ b/app/pe/pe_rcstag.py @@ -0,0 +1,217 @@ +# -*- 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 new file mode 100644 index 0000000000000000000000000000000000000000..9ed2418b63d45e684aee98e16687ef3e1bc6283c --- /dev/null +++ b/app/pe/pe_semtag.py @@ -0,0 +1,310 @@ +# -*- 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"<li><code>{tag}</code></li>" 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 <em>{self.formsemestre.titre_annee()}</em> + contient le{s} tag{s} réservé{s} suivant : + <ul> + {liste_intersection} + </ul> + 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 new file mode 100644 index 0000000000000000000000000000000000000000..68be877275c49b8f317400a186cb2829fc7c0665 --- /dev/null +++ b/app/pe/pe_tabletags.py @@ -0,0 +1,263 @@ +# -*- 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/templates/pe/pe_view_sem_recap.j2 b/app/templates/pe/pe_view_sem_recap.j2 new file mode 100644 index 0000000000000000000000000000000000000000..756b7f87054ae7b9cb6fc7ea5bc0f7d79376c8d3 --- /dev/null +++ b/app/templates/pe/pe_view_sem_recap.j2 @@ -0,0 +1,75 @@ +{% extends "sco_page.j2" %} + +{% block styles %} + {{super()}} + <style> +.div-warning { + color: red; + background-color: yellow; + font-size: 120%; + border: 2px solid red; + border-radius: 12px; + padding: 12px; + margin-top: 16px; + margin-bottom: 16px; + width: fit-content; +} + </style> +{% endblock styles %} + +{% block app_content %} + +<h2>Génération des avis de poursuites d'études (V2 BUT EXPERIMENTALE)</h2> + +<div class="div-warning"> + Fonction expérimentale pour le BUT : travaux en cours, merci de tester + et de faire part de vos expériences sur le Discord. +</div> + +<div class="help"> + <p> + 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. + <br> + De nombreux aspects sont paramétrables: + <a href="https://scodoc.org/AvisPoursuiteEtudes" + target="_blank" rel="noopener noreferrer"> + voir la documentation + </a> (en cours de révision). + </p> +</div> + + <h3>Avis de poursuites d'études de la promo {{ annee_diplome }}</h3> + + <div class="help"> + Seront (a minima) pris en compte les étudiants ayant été inscrits aux semestres suivants : + + <ul> + {% for fid in cosemestres %} + <li> + {{ cosemestres[fid].titre_annee() }} + </li> + {% endfor %} + </ul> + </div> + + <div> + <progress id="pe_progress" style="visibility: hidden"></progress> + <br> + <button onclick="submitPEGeneration()">Générer les documents de la promo {{ annee_diplome }}</button> + </div> + + <form method="post" id="pe_generation" style="visibility: hidden"> + <input type="submit" + onclick="submitPEGeneration()" value=""/> + <input type="hidden" name="formsemestre_id" value="{{formsemestre.id}}"> + </form> + + <script> + function submitPEGeneration() { + // document.getElementById("pe_progress").style.visibility = 'visible'; + document.getElementById("pe_generation").submit(); //attach an id to your form + } + </script> +{% endblock app_content %} \ No newline at end of file diff --git a/migrations/versions/2e4875004e12_etudiant_annotations.py b/migrations/versions/2e4875004e12_etudiant_annotations.py new file mode 100644 index 0000000000000000000000000000000000000000..2b06341b2109cd35f626193cc1ca73a02be99e79 --- /dev/null +++ b/migrations/versions/2e4875004e12_etudiant_annotations.py @@ -0,0 +1,56 @@ +"""etudiant_annotations : ajoute clé externe etudiant et moduleimpl + +Revision ID: 2e4875004e12 +Revises: 3fa988ff8970 +Create Date: 2024-02-11 12:10:36.743212 + +""" + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "2e4875004e12" +down_revision = "3fa988ff8970" +branch_labels = None +depends_on = None + + +def upgrade(): + + # Supprime les annotations orphelines + op.execute( + """DELETE FROM etud_annotations + WHERE etudid NOT IN (SELECT id FROM identite); + """ + ) + # Ajoute clé: + with op.batch_alter_table("etud_annotations", schema=None) as batch_op: + batch_op.create_foreign_key(None, "identite", ["etudid"], ["id"]) + + # Et modif liée au commit 072d013590abf715395bc987fb48de49f6750527 + with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op: + batch_op.drop_constraint( + "notes_moduleimpl_responsable_id_fkey", type_="foreignkey" + ) + batch_op.create_foreign_key( + None, "user", ["responsable_id"], ["id"], ondelete="SET NULL" + ) + + # cet index en trop trainait depuis longtemps... + with op.batch_alter_table("assiduites", schema=None) as batch_op: + batch_op.drop_index("ix_assiduites_user_id") + + +def downgrade(): + with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op: + batch_op.drop_constraint(None, type_="foreignkey") + batch_op.create_foreign_key( + "notes_moduleimpl_responsable_id_fkey", "user", ["responsable_id"], ["id"] + ) + + with op.batch_alter_table("etud_annotations", schema=None) as batch_op: + batch_op.drop_constraint(None, type_="foreignkey") + + with op.batch_alter_table("assiduites", schema=None) as batch_op: + batch_op.create_index("ix_assiduites_user_id", ["user_id"], unique=False) diff --git a/tests/api/exemple-api-list-modules.py b/tests/api/exemple-api-list-modules.py new file mode 100644 index 0000000000000000000000000000000000000000..2cc146593f53072d365ad3dbcd7fe74264af6a93 --- /dev/null +++ b/tests/api/exemple-api-list-modules.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +"""Exemple utilisation API ScoDoc 9 avec jeton obtenu par basic authentication + + Extraction de la liste de tous les modules d'une année scolaire + + Usage: + cd /opt/scodoc/tests/api + python -i exemple-api-list-modules.py + + +Pour utiliser l'API, (sur une base quelconque): +``` +cd /opt/scodoc/tests/api + +python -i exemple-api-list-modules.py +>>> admin_h = get_auth_headers("admin", "xxx") +>>> GET("/etudiant/etudid/14806", headers=admin_h) +``` + +Créer éventuellement un fichier `.env` dans /opt/scodoc/tests/api +avec la config du client API: +``` + SCODOC_URL = "http://localhost:5000/" + API_USER = "admin" + API_PASSWORD = "test" +``` +""" + +from pprint import pprint as pp +import requests +import sys +import urllib3 +from setup_test_api import ( + API_PASSWORD, # lus de l'environnement ou du .env + API_URL, + API_USER, + APIError, + CHECK_CERTIFICATE, + get_auth_headers, + GET, + POST_JSON, + SCODOC_URL, +) + + +def logout_api_user(): + r = requests.delete(API_URL + "/tokens", headers=HEADERS, verify=CHECK_CERTIFICATE) + assert r.status_code == 204 + + +if not CHECK_CERTIFICATE: + urllib3.disable_warnings() + +# Si vous n'utilisez pas .env: +API_USER = "lecteur_api" +API_PASSWORD = "azerty" + +HEADERS = get_auth_headers(API_USER, API_PASSWORD) +print("connected to ScoDoc") + +# Liste des formsemestres de l'année scolaire +ANNEE_SCOLAIRE = 2023 # int, année de début de l'année scolaire + +formsemestres = GET( + f"/formsemestres/query?annee_scolaire={ANNEE_SCOLAIRE}", headers=HEADERS +) +print(f"Nombre de semestres: {len(formsemestres)}") + +r = [] # liste de dict, résultat +for formsemestre in formsemestres: + print(f"requesting {formsemestre['titre_num']}") + programme = GET(f"/formsemestre/{formsemestre['id']}/programme", headers=HEADERS) + for mod_type in ("ressources", "saes", "modules"): + mods = programme[mod_type] + for mod in mods: + r.append( + { + "dept": formsemestre["departement"]["acronym"], + "sem_id": formsemestre["id"], + "sem_titre": formsemestre["titre"], + "sem_modalite": formsemestre["modalite"], + "sem_etape": formsemestre["etape_apo"], + "mod_type": mod_type[:-1], + "mod_code": mod["module"]["code"], + "mod_titre": mod["module"]["titre"], + "mod_abbrev": mod["module"]["abbrev"], + "mod_code_apogee": mod["module"]["code_apogee"], + "modimpl_code_apogee": mod["code_apogee"], + } + ) + +# Dump to csv file +SEP = "\t" +with open("/tmp/modules.csv", "w", encoding="utf-8") as f: + f.write(SEP.join(r[0]) + "\n") + for l in r: + # on élimine les éventuels séparateurs des champs... + f.write(SEP.join([str(x).replace(SEP, " ") for x in l.values()]) + "\n")