diff --git a/app/pe/pe_etudiant.py b/app/pe/pe_etudiant.py new file mode 100644 index 000000000..7c5581846 --- /dev/null +++ b/app/pe/pe_etudiant.py @@ -0,0 +1,363 @@ +# -*- 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 app.pe.pe_tools as pe_tools +from app.models import FormSemestre, Identite +from app.pe.pe_tools import pe_print +from app.scodoc import ( + sco_etud, + codes_cursus, + sco_formsemestre, + sco_formsemestre_inscriptions, + sco_report, +) +import datetime + + +class EtudiantsJuryPE: + """Classe centralisant la gestion des étudiants à prendre en compte dans un jury de PE""" + + def __init__(self): + """ """ + + "Les identités des étudiants du jury" + self.identites = {} # ex. ETUDINFO_DICT + "Les cursus (semestres suivis, abandons, ...)" + self.cursus = {} + "Les etudids des étudiants à considérer au jury" + self.etudiants_jury_ids = {} + "Les etudids des étudiants dont il faut calculer les moyennes/classements" + self.etudiants_ids = {} + "Les formsemestres dont il faut calculer les moyennes" + self.formsemestres_jury_ids = {} + + def find_etudiants(self, annee_diplome: int, formation_id: int): + """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`` + dans la formation ``formation_id``. + + Les données obtenues sont stockées dans les attributs de EtudiantsJuryPE. + + Args: + annee_diplome: L'année de diplomation + formation_id: L'identifiant de la formation + + *Remarque* : ex: JuryPE.get_etudiants_in_jury() + """ + "Les cosemestres donnant lieu à même année de diplome" + cosemestres = pe_tools.get_cosemestres_diplomants( + annee_diplome, None # formation_id, + ) + pe_tools.pe_print( + "1) Recherche des coSemestres -> %d trouvés" % len(cosemestres) + ) + + """Les étudiants inscrits dans les co-semestres (ceux du jury mais aussi d'autres ayant été réorientés ou ayant abandonnés)""" + pe_tools.pe_print("2) Liste des étudiants dans les différents co-semestres") + self.etudiants_ids = get_etudiants_dans_semestres( + cosemestres + ) # étudiants faisant partie de tous les cosemestres + pe_tools.pe_print(" => %d étudiants trouvés" % len(self.etudiants_ids)) + + # L'analyse des parcours étudiants pour déterminer leur année effective de diplome avec prise en compte des redoublements, des abandons, .... + pe_tools.pe_print("3) Analyse des parcours individuels des étudiants") + + no_etud = 0 + for (no_etud, etudid) in enumerate(self.etudiants_ids): + self.add_etudid(etudid, cosemestres) + if (no_etud + 1) % 10 == 0: + pe_tools.pe_print((no_etud + 1), " ", end="") + no_etud += 1 + pe_tools.pe_print() + + """Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris""" + self.etudiants_jury_ids = self.get_etudids(annee_diplome) + + """Les étudiants dont il faut calculer les moyennes""" + self.etudiants_ids = {etudid for etudid in self.cursus} + + """Les formsemestres (des étudiants) dont il faut calculer les moyennes""" + self.formsemestres_jury_ids = self.get_formsemestres_jury() + + # Synthèse + pe_tools.pe_print(f" => {len(self.etudiants_jury_ids)} étudiants à diplômer en {annee_diplome}") + nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_ids) + pe_tools.pe_print(f" => {nbre_abandons} étudiants éliminer pour abandon") + pe_tools.pe_print(f" => quelques étudiants futurs diplômés : " + ", ".join([str(etudid) for etudid in list(self.etudiants_jury_ids)[:10]])) + + def get_etudids(self, annee_diplome: int, ordre="aucun") -> list: + """Liste des etudid des étudiants 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é. + + Si l'``ordre`` est précisé, trie la liste par ordre alphabétique de etat_civil + + Args: + annee_diplome: Année de diplomation visée pour le jury + ordre: Un ordre de tri + + Returns: + Une liste contenant des ``etudids`` + + Note: ex JuryPE.get_etudids_du_jury() + """ + etudids = [ + etudid + for (etudid, donnees) in self.cursus.items() + if donnees["diplome"] == annee_diplome and not donnees["abandon"] + ] + if ordre == "alphabetique": # Tri alphabétique + etudidsAvecNom = [ + (etudid, etud["etat_civil"]) + for (etudid, etud) in self.cursus.items() + if etudid in etudids + ] + etudidsAvecNomTrie = sorted(etudidsAvecNom, key=lambda col: col[1]) + etudids = [etud[0] for etud in etudidsAvecNomTrie] + return etudids + + def add_etudid(self, etudid: int, cosemestres): + """Ajoute un étudiant à ceux qui devront être traités pendant le jury pouvant être : + + * des étudiants sur lesquels le jury va statuer (année de diplômation du jury considéré) + * des étudiants qui ne seront pas considérés dans le jury mais ont participé dans leur scolarité + à un (ou plusieurs) semestres communs aux étudiants du jury (et impacteront les classements) + + L'ajout consiste : + + * à insérer une entrée pour l'étudiant en mémorisant 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) + * à chercher ses semestres valides (formsemestre_id) et ses années valides (formannee_id), + c'est-à-dire ceux pour lesquels il faudra prendre en compte ses notes dans les calculs de + moyenne (type 1A=S1+S2/2) + + 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 + Note: ex JuryPE.add_etudid_to_jury() + """ + + + """L'identité de l'étudiant""" + identite = Identite.get_etud(etudid) + self.identites[etudid] = identite + + """Le cursus global de l'étudiant""" + semestres_etudiant = { + frmsem.formsemestre_id: frmsem for frmsem in identite.get_formsemestres() + } + + self.cursus[etudid] = { + "etudid": etudid, # les infos sur l'étudiant + "etat_civil": identite.etat_civil, # Ajout à la table jury + "diplome": annee_diplome(identite), # Le date prévisionnelle de son diplôme + "formsemestres": semestres_etudiant # les semestres de l'étudiant + } + + """ Est-il réorienté / démissionnaire ou a-t-il arrêté volontairement sa formation ?""" + self.cursus[etudid]["abandon"] = arret_de_formation( + identite, cosemestres + ) + + """Tri des semestres par n° de semestre""" + for nom_sem in pe_tools.TOUS_LES_SEMESTRES: + numero_sem = int(nom_sem[1]) + 1 + self.cursus[etudid][nom_sem] = {fid: semestres_etudiant[fid] + for fid in semestres_etudiant + if semestres_etudiant[fid].semestre_id == numero_sem} + + + """Tri des semestres par aggrégat""" + for parcours in pe_tools.TOUS_LES_AGGREGATS: + """L'aggrégat considéré""" + noms_semestre_de_aggregat = pe_tools.PARCOURS[parcours]["aggregat"] + + self.cursus[etudid][parcours] = {} + for nom_sem in noms_semestre_de_aggregat: + self.cursus[etudid][parcours] = self.cursus[etudid][parcours] | self.cursus[etudid][nom_sem] + + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print( + parcours + "=" + str(self.cursus[etudid][parcours]), + end="", + ) + + + def get_formsemestres_jury(self): + """Ayant connaissance des étudiants dont il faut calculer les moyennes pour + le jury PE (attribut `self.etudiant_ids), renvoie l'ensemble des formsemestres + de leur cursus, dont il faudra calculer la moyenne. + + Returns: + Un ensemble de formsemestres + """ + formsemestres = {} + for etudid in self.etudiants_ids: + formsem_etudid = set(self.cursus[etudid].keys()) + formsemestres = formsemestres | formsem_etudid + return formsemestres + + +def get_etudiants_dans_semestres(semestres: dict[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 (fid, sem) in semestres.items(): # pour chacun des semestres de la liste + etudiants_du_sem = {ins.etudid for ins in sem.inscriptions} + + 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 annee_diplome(identite: Identite) -> int: + """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 + + NOTE: Pourrait être déplacé dans app.models.etudiants.Identite + """ + formsemestres = identite.get_formsemestres() + if formsemestres: + return max( + [ + pe_tools.get_annee_diplome_semestre(sem_base) + for sem_base in formsemestres + ] + ) + else: + return None + + +def semestres_etudiant(etudid: int, semestre_id=None): + """La liste des semestres BUT d'un étudiant + pour un semestre_id (parmi 1, 2, 3, 4, 5, 6) donné + en fonction de ses infos d'etud (cf. sco_etud.get_etud_info(etudid=etudid, filled=True)[0]), + les semestres étant triés par ordre décroissant. + Si semestre_id == None renvoie tous les semestres + + NOTE:: ex:: JuryPE.get_semestresBUT_d_un_etudiant() + TODO:: A revoir""" + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + nbre_semestres = int(pe_tools.AGGREGAT_DIPLOMANT[0]) # 6 + if semestre_id == None: + sesSems = [ + sem for sem in etud["sems"] if 1 <= sem["semestre_id"] <= nbre_semestres + ] + else: + sesSems = [sem for sem in etud["sems"] if sem["semestre_id"] == semestre_id] + return sesSems + + +def arret_de_formation(identite: 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 ; dans ce cas, utilise les + décisions prises dans les jury de semestres (code NAR pour réorienté & DEM pour démissionnaire) + + * d'un arrêt volontaire : l'étudiant disparait des listes d'inscrit, sans + pour autant avoir été indiqué NAR ou DEM. Dans ce cas, recherche son dernier semestre validé et + regarde s'il n'existe pas parmi les semestres existants dans Scodoc un semestre postérieur + (en terme de date de début) + de n° au moins égal à celui de son dernier semestre valide dans lequel il aurait pu + s'inscrire mais ne l'a pas fait. + + Args: + identite: 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 avec l'accès aux résultats des jury prévu par Scodoc9 + """ + etudid = identite.etudid + reponse = False + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + (code, parcours) = sco_report.get_code_cursus_etud(etud) + + # Est-il réorienté ou démissionnaire ? + if ( + len(codes_cursus.CODES_SEM_REO & set(parcours.values())) > 0 + ): # Eliminé car NAR apparait dans le parcours + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print(" -> à éliminer car réorienté (NAR)") + return True + + if "DEM" in list(parcours.values()): # Eliminé car DEM + if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: + pe_tools.pe_print(" -> à éliminer car DEM") + return True + + # A-t-il arrêté volontairement sa formation ? + dernier_formsemestre = identite.get_formsemestres()[0] + + # 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 ( + sem.formsemestre_id != dernier_formsemestre.formsemestre_id + and sem.date_debut.year >= dernier_formsemestre.date_debut.year + and sem.semestre_id > dernier_formsemestre.semestre_id + ): # 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 diff --git a/app/pe/pe_tools.py b/app/pe/pe_tools.py index 133a07883..a2264fc0b 100644 --- a/app/pe/pe_tools.py +++ b/app/pe/pe_tools.py @@ -46,9 +46,11 @@ from flask import g import app.scodoc.sco_utils as scu from app import log +from app.models import FormSemestre +from app.scodoc import sco_formsemestre from app.scodoc.sco_logos import find_logo -PE_DEBUG = 0 +PE_DEBUG = 1 if not PE_DEBUG: # log to notes.log @@ -74,10 +76,96 @@ PE_LOCAL_FOOTER_TMPL = REP_LOCAL_AVIS + "local/modeles/un_footer.tex" # ---------------------------------------------------------------------------------------- +PARCOURS = { + "S1": { + "aggregat": ["S1"], + "ordre": 1, + "affichage_court": "S1", + "affichage_long": "Semestre 1", + }, + "S2": { + "aggregat": ["S2"], + "ordre": 2, + "affichage_court": "S2", + "affichage_long": "Semestre 2", + }, + "1A": { + "aggregat": ["S1", "S2"], + "ordre": 3, + "affichage_court": "1A", + "affichage_long": "1ère année", + }, + "S3": { + "aggregat": ["S3"], + "ordre": 4, + "affichage_court": "S3", + "affichage_long": "Semestre 3", + }, + "S4": { + "aggregat": ["S4"], + "ordre": 5, + "affichage_court": "S4", + "affichage_long": "Semestre 4", + }, + "2A": { + "aggregat": ["S3", "S4"], + "ordre": 6, + "affichage_court": "2A", + "affichage_long": "2ème année", + }, + "3S": { + "aggregat": ["S1", "S2", "S3"], + "ordre": 7, + "affichage_court": "S1+S2+S3", + "affichage_long": "BUT du semestre 1 au semestre 3", + }, + "4S": { + "aggregat": ["S1", "S2", "S3", "S4"], + "ordre": 8, + "affichage_court": "BUT", + "affichage_long": "BUT du semestre 1 au semestre 4", + }, + "S5": { + "aggregat": ["S5"], + "ordre": 9, + "affichage_court": "S5", + "affichage_long": "Semestre 5", + }, + "S6": { + "aggregat": ["S6"], + "ordre": 10, + "affichage_court": "S6", + "affichage_long": "Semestre 6", + }, + "3A": { + "aggregat": ["S5", "S6"], + "ordre": 11, + "affichage_court": "3A", + "affichage_long": "3ème année", + }, + "5S": { + "aggregat": ["S1", "S2", "S3", "S4", "S5"], + "ordre": 12, + "affichage_court": "S1+S2+S3+S4+S5", + "affichage_long": "BUT du semestre 1 au semestre 5", + }, + "6S": { + "aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"], + "ordre": 13, + "affichage_court": "BUT", + "affichage_long": "BUT (tout semestre inclus)", + }, +} +AGGREGAT_DIPLOMANT = ( + "6S" # aggrégat correspondant à la totalité des notes pour le diplôme +) +TOUS_LES_SEMESTRES = PARCOURS[AGGREGAT_DIPLOMANT]["aggregat"] +TOUS_LES_AGGREGATS = [cle for cle in PARCOURS.keys() if not cle.startswith("S")] +TOUS_LES_PARCOURS = list(PARCOURS.keys()) + # ---------------------------------------------------------------------------------------- -def print_semestres_description(sems, - avec_affichage_debug=False): +def print_semestres_description(sems, avec_affichage_debug=False): """Dediee a l'affichage d'un semestre pour debug du module""" def chaine_semestre(sem): @@ -166,10 +254,7 @@ def list_directory_filenames(path): return R -def add_local_file_to_zip(zipfile, - ziproot, - pathname, - path_in_zip): +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) @@ -177,8 +262,7 @@ def add_local_file_to_zip(zipfile, # zipfile.writestr(rooted_path_in_zip, data) -def add_refs_to_register(register, - directory): +def add_refs_to_register(register, directory): """Ajoute les fichiers trouvés dans directory au registre (dictionaire) sous la forme filename => pathname """ @@ -188,8 +272,7 @@ def add_refs_to_register(register, register[filename] = pathname -def add_pe_stuff_to_zip(zipfile, - ziproot): +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 @@ -218,6 +301,104 @@ def add_pe_stuff_to_zip(zipfile, ) +# ---------------------------------------------------------------------------------------- +def get_annee_diplome_semestre(sem_base, nbre_sem_formation=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 ?? + nbreSemRestant = ( + nbre_sem_formation - sem_id + ) # nombre de semestres restant avant diplome + nbreAnRestant = nbreSemRestant // 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 = nbreSemRestant % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1 + increment = decalage * (1 - delta) + return annee_fin + nbreAnRestant + increment + + +def get_cosemestres_diplomants(annee_diplome: int, formation_id: int) -> list: + """Ensemble des cosemestres donnant lieu à diplomation à l'``annee_diplome`` + et s'intégrant à la formation donnée par son ``formation_id``. + + **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 + + Si formation_id == None, ne prend pas en compte l'identifiant de formation + TODO:: A raccrocher à un programme + + Args: + annee_diplome: L'année de diplomation + formation_id: L'identifiant de la formation + """ + tousLesSems = ( + sco_formsemestre.do_formsemestre_list() + ) # tous les semestres memorisés dans scodoc + + if formation_id: + cosemestres_fids = { + sem["id"] + for sem in tousLesSems + if get_annee_diplome_semestre(sem) == annee_diplome + and sem["formation_id"] == formation_id + } + else: + cosemestres_fids = { + sem["id"] + for sem in tousLesSems + 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 + + # ---------------------------------------------------------------------------------------- # Variable pour le debug des avislatex (en squeezant le calcul du jury souvent long) JURY_SYNTHESE_POUR_DEBUG = { diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index 71c507600..779e049dc 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -51,12 +51,38 @@ from app.pe import pe_avislatex def _pe_view_sem_recap_form(formsemestre_id): + sem_base = FormSemestre.get_formsemestre(formsemestre_id) + if not sem_base.formation.is_apc() or sem_base.formation.get_cursus().NB_SEM < 6: + H = [ + html_sco_header.sco_header(page_title="Avis de poursuite d'études"), + f"""
+ Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de
+ poursuites d'études.
+
+ De nombreux aspects sont paramétrables:
+
+ voir la documentation
+ .
+ Cette fonction (en Scodoc9) n'est prévue que pour le BUT.
+
+ Rendez-vous donc sur un semestre de BUT.
+
Génération des avis de poursuites d'études
Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de
- poursuites d'études.
+ poursuites d'études pour les étudiants diplômés en {diplome}.
De nombreux aspects sont paramétrables: