# -*- 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_comp as pe_comp from app.models import FormSemestre, Identite from app.pe.pe_comp import pe_print 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 "Les identités des étudiants traités pour le jury" self.identites = {} # ex. ETUDINFO_DICT "Les cursus (semestres suivis, abandons) des étudiants" self.cursus = {} """Les aggrégats des semestres suivis (par ex: 3S=S1+S2+S3 à prendre en compte avec d'éventuels redoublements) des étudiants""" self.trajectoires = {} "Les etudids des étudiants à considérer au jury (ceux qui seront effectivement diplômés)" self.etudiants_diplomes = {} self.diplomes_ids = {} "Les etudids des étudiants dont il faut calculer les moyennes/classements (même si d'éventuels abandons)" self.etudiants_ids = {} """Les é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" def find_etudiants(self, 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``. XXX TODO voir si on garde formation_id qui n'est pas utilisé ici Les données obtenues sont stockées dans les attributs de EtudiantsJuryPE. formation_id: L'identifiant de la formation (inutilisé) *Remarque* : ex: JuryPE.get_etudiants_in_jury() """ cosemestres = pe_comp.get_cosemestres_diplomants(self.annee_diplome, None) self.cosemestres = cosemestres pe_comp.pe_print(f"1) Recherche des coSemestres -> {len(cosemestres)} trouvés") pe_comp.pe_print("2) Liste des étudiants dans les différents co-semestres") self.etudiants_ids = get_etudiants_dans_semestres(cosemestres) pe_comp.pe_print( " => %d étudiants trouvés dans les cosemestres" % len(self.etudiants_ids) ) # Analyse des parcours étudiants pour déterminer leur année effective de diplome # avec prise en compte des redoublements, des abandons, .... pe_comp.pe_print("3) Analyse des parcours individuels des étudiants") no_etud = 0 for no_etud, etudid in enumerate(self.etudiants_ids): identite = Identite.get_etud(etudid) self.identites[etudid] = identite """identités des étudiants""" # Analyse son cursus self.analyse_etat_etudiant(etudid, cosemestres) # Analyse son parcours pour atteindre chaque semestre de la formation self.structure_cursus_etudiant(etudid) if (no_etud + 1) % 10 == 0: pe_comp.pe_print(f"{no_etud + 1}") no_etud += 1 pe_comp.pe_print() # 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) """Les étudiants dont il faut calculer les moyennes""" self.formsemestres_jury_ids = self.get_formsemestres() """Les formsemestres (des étudiants) dont il faut calculer les moyennes""" # Synthèse pe_comp.pe_print( f" => {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}" ) nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_diplomes) pe_comp.pe_print(f" => {nbre_abandons} étudiants éliminer pour abandon") pe_comp.pe_print( f" => {len(self.formsemestres_jury_ids)} semestres dont il faut calculer la moyenne" ) pe_comp.pe_print( " => quelques étudiants futurs diplômés : " + ", ".join([str(etudid) for etudid in list(self.etudiants_diplomes)[:10]]) ) pe_comp.pe_print( " => semestres dont il faut calculer les moyennes : " + ", ".join([str(fid) for fid in list(self.formsemestres_jury_ids)]) ) # Les abandons : self.abandons = sorted( [ cursus["nom"] for etudid, cursus in self.cursus.items() if etudid not in self.diplomes_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 in self.cursus if self.cursus[etudid]["diplome"] == self.annee_diplome and self.cursus[etudid]["abandon"] is False ] 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 = { frmsem.formsemestre_id: frmsem for frmsem in formsemestres if frmsem.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 réorienté / démissionnaire 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 nom_sem in pe_comp.TOUS_LES_SEMESTRES: i = int(nom_sem[1]) # le n° du semestre semestres_i = { fid: semestres_significatifs[fid] for fid in semestres_significatifs if semestres_significatifs[fid].semestre_id == i } # les semestres de n°i de l'étudiant self.cursus[etudid][nom_sem] = semestres_i def get_trajectoire(self, etudid: int, formsemestre_final: FormSemestre, nom_aggregat: str): """Ensemble des semestres parcourus par un étudiant pour l'amener à un semestre terminal. Si nom_aggregat 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 nom_aggregat 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: etudid: L'identifiant de l'étudiant formsemestre_final: le semestre final visé """ numero_semestre_terminal = formsemestre_final.semestre_id semestres_significatifs = self.get_semestres_significatifs(etudid) if nom_aggregat.startswith("S"): # les semestres numero_semestres_possibles =[numero_semestre_terminal] elif nom_aggregat.endswith("A"): # les années numero_semestres_possibles = [int(sem[-1]) for sem in pe_comp.PARCOURS[nom_aggregat]["aggregat"]] assert numero_semestre_terminal in numero_semestres_possibles else: # les xS = tous les semestres jusqu'à Sx (pax ex: des 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_formsemestres_terminaux_aggregat(self, aggregat: str): """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.semestre_final.formsemestre_id formsemestres_terminaux[fid] = trajectoire.semestre_final return formsemestres_terminaux def get_formsemestres(self, semestres_recherches=None): """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. Les formsemestres sont limités à ceux indiqués dans ``semestres_recherches``. Args: semestres_recherches: Une liste ou une chaine de caractères parmi : * None : pour obtenir tous les formsemestres du jury * 'Si' : pour obtenir les semestres de n° i (par ex. 'S1') * 'iA' : pour obtenir les semestres de l'année i (par ex. '1A' donne ['S1, 'S2']) * '3S', '4S' : pour obtenir les combinaisons de semestres définies par les aggrégats Returns: Un dictionnaire de la forme ``{fid: FormSemestre(fid)}`` Remarque: Une liste de la forme ``[ 'Si', 'iA' , ... ]`` (combinant les formats précédents) est possible. """ if semestres_recherches is None: # Appel récursif pour obtenir tous les semestres (validants) semestres = self.get_formsemestres(pe_comp.AGGREGAT_DIPLOMANT) return semestres elif isinstance(semestres_recherches, list): # Appel récursif sur tous les éléments de la liste semestres = {} for elmt in semestres_recherches: semestres_elmt = self.get_formsemestres(elmt) semestres = semestres | semestres_elmt return semestres elif ( isinstance(semestres_recherches, str) and semestres_recherches in pe_comp.TOUS_LES_AGGREGATS ): # Cas d'un aggrégat avec appel récursif sur toutes les entrées de l'aggrégat semestres = self.get_formsemestres( pe_comp.PARCOURS[semestres_recherches]["aggregat"] ) return semestres elif ( isinstance(semestres_recherches, str) and semestres_recherches in pe_comp.TOUS_LES_SEMESTRES ): # semestres_recherches est un nom de semestre de type S1, # pour une recherche parmi les étudiants à prendre en compte # dans le jury (diplômé et redoublants non diplômé) nom_sem = semestres_recherches semestres = {} for etudid in self.etudiants_ids: if self.cursus[etudid][nom_sem]: semestres = semestres | self.cursus[etudid][nom_sem] return semestres else: raise ValueError("Probleme de paramètres d'appel dans get_formsemestreids") def nbre_etapes_max_diplomes(self): """Connaissant les étudiants diplomes du jury PE, nombre de semestres (étapes) maximum suivis par les étudiants du jury. """ nbres_semestres = [] for etudid in self.diplomes_ids: nbres_semestres.append(self.cursus[etudid]["nb_semestres"]) return max(nbres_semestres) 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_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: """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 """ 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