# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2023 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 # ############################################################################## """ # Outil de comparaison Apogée/ScoDoc (J.-M. Place, Jan 2020) ## fonctionalités Le menu 'synchronisation avec Apogée' ne permet pas de traiter facilement les cas où un même code étape est implementé dans des formsemestres différents. On ajoute à la page de description des ensembles de semestres une section permettant de faire le point sur les cas particuliers. Cette section est composée de deux parties: * Une partie effectif où figurent le nombre d'étudiants selon une répartition par semestre (en ligne) et par code étape (en colonne). On ajoute également des colonnes/lignes correspondant à des anomalies (étudiant sans code étape, sans semestre, avec deux semestres, sans NIP, etc.). * Une seconde partie présente la liste des étudiants. Il est possible qu'un même nom figure deux fois dans la liste (si on a pas pu faire la correspondance entre une inscription Apogée et un étudiant d'un semestre, par exemple). L'activation d'un des nombres du tableau 'effectifs' restreint l'affichage de la liste aux étudiants qui contribuent à ce nombre. ## Réalisation Les modifications logicielles portent sur: ### La création d'une classe sco_etape_bilan.py Cette classe compile la totalité des données: ** Liste des semestres ** Listes des étapes ** Liste des étudiants ** constitution des listes d'anomalies Cette classe explore la suite semestres du semset. Pour chaque semestre, elle recense les étudiants du semestre et les codes étapes concernés, puis tous les codes étapes (toujours en important les étudiants de l'étape via le portail). Enfin on dispatch chaque étudiant dans une case - soit ordinaire, soit correspondant à une anomalie. ### Modification de sco_etape_apogee_view.py Pour insertion de l'affichage ajouté. ### Modification de sco_semset.py Affichage proprement dit. ### Modification de sco_formsemestre.py Modification/ajout de la méthode sem_in_semestre_scolaire pour permettre l'inscription de semestres décalés (S1 en septembre, ...). Le filtrage s'effectue sur la date et non plus sur la parité du semestre (1-3/2-4). """ import json from flask import url_for, g from app.scodoc.sco_portal_apogee import get_inscrits_etape from app import log from app.scodoc.sco_utils import annee_scolaire_debut from app.scodoc.gen_tables import GenTable COL_PREFIX = "COL_" # Les indicatifs sont des marqueurs de classe CSS insérés dans la table étudiant # et utilisés par le javascript pour permettre un filtrage de la liste étudiants # sur un 'cas' considéré # indicatifs COL_CUMUL = "C9" ROW_CUMUL = "R9" # Constante d'anomalie PAS_DE_NIP = "C1" PAS_D_ETAPE = "C2" PLUSIEURS_ETAPES = "C3" PAS_DE_SEMESTRE = "R4" PLUSIEURS_SEMESTRES = "R5" NIP_NON_UNIQUE = "U" FLAG = { PAS_DE_NIP: "A", PAS_D_ETAPE: "B", PLUSIEURS_ETAPES: "C", PAS_DE_SEMESTRE: "D", PLUSIEURS_SEMESTRES: "E", NIP_NON_UNIQUE: "U", } class DataEtudiant(object): """ Structure de donnée des informations pour un étudiant """ def __init__(self, nip="", etudid=""): self.nip = nip self.etudid = etudid self.data_apogee = None self.data_scodoc = None self.etapes = set() # l'ensemble des étapes où il est inscrit self.semestres = set() # l'ensemble des formsemestre_id où il est inscrit self.tags = set() # les anomalies relevées self.ind_row = "-" # là où il compte dans les effectifs (ligne et colonne) self.ind_col = "-" def add_etape(self, etape): self.etapes.add(etape) def add_semestre(self, formsemestre_id: int): self.semestres.add(formsemestre_id) def set_apogee(self, data_apogee): self.data_apogee = data_apogee def set_scodoc(self, data_scodoc): self.data_scodoc = data_scodoc def add_tag(self, tag): self.tags.add(tag) def set_ind_row(self, indicatif): self.ind_row = indicatif def set_ind_col(self, indicatif): self.ind_col = indicatif def get_identity(self): """ Calcul le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée) :return: L'identité calculée """ if self.data_scodoc is not None: return self.data_scodoc["nom"] + self.data_scodoc["prenom"] else: return self.data_apogee["nom"] + self.data_apogee["prenom"] def _help() -> str: return """
Explications sur les tableaux des effectifs et liste des étudiants

Le tableau des effectifs présente le nombre d'étudiants selon deux critères:

""" def entete_liste_etudiant() -> str: return """ """ class EtapeBilan: """ Structure de donnée représentation l'état global de la comparaison ScoDoc/Apogée """ def __init__(self): self.semestres = {} "Dictionnaire des formsemestres du semset (formsemestre_id -> semestre)" self.etapes = [] # Liste des étapes apogées du semset (clé_apogée) # pour les descriptions qui suivent: # cle_etu = nip si non vide, sinon etudid # data_etu = { nip, etudid, data_apogee, data_scodoc } self.etudiants = {} "cle_etu -> data_etu" self.keys_etu = {} "nip -> [ etudid* ]" self.etu_semestre = {} "semestre -> { key_etu }" self.etu_etapes = {} "etape -> { key_etu }" self.repartition = {} "(ind_row, ind_col) -> nombre d étudiants" self.tag_count = {} "nombre d'anomalies détectées (par type d'anomalie)" # on collectionne les indicatifs trouvés pour n'afficher que les indicatifs 'utiles' self.indicatifs = {} self.top_row = 0 self.top_col = 0 self.all_rows_ind = [PAS_DE_SEMESTRE, PLUSIEURS_SEMESTRES] self.all_cols_ind = [PAS_DE_NIP, PAS_D_ETAPE, PLUSIEURS_ETAPES] self.all_rows_str = None self.all_cols_str = None self.titres = { PAS_DE_NIP: "PAS_DE_NIP", PAS_D_ETAPE: "PAS_D_ETAPE", PLUSIEURS_ETAPES: "PLUSIEURS_ETAPES", PAS_DE_SEMESTRE: "PAS_DE_SEMESTRE", PLUSIEURS_SEMESTRES: "PLUSIEURS_SEMESTRES", NIP_NON_UNIQUE: "NIP_NON_UNIQUE", } def inc_tag_count(self, tag): if tag not in self.tag_count: self.tag_count[tag] = 0 self.tag_count[tag] += 1 def set_indicatif(self, item, as_row): """item = semestre ou key_etape""" if as_row: indicatif = "R" + chr(self.top_row + 97) self.all_rows_ind.append(indicatif) self.top_row += 1 else: indicatif = "C" + chr(self.top_col + 97) self.all_cols_ind.append(indicatif) self.top_col += 1 self.indicatifs[item] = indicatif if self.top_row > 26: log("Dépassement (plus de 26 semestres dans la table diagnostic") if self.top_col > 26: log("Dépassement (plus de 26 étapes dans la table diagnostic") def add_sem(self, sem: dict): """ Prise en compte d'un semestre dans le bilan. * ajoute le semestre et les étudiants du semestre * ajoute les étapes du semestre et (via portail) les étudiants pour ces codes étapes :param semestre: Le semestre à prendre en compte :return: None """ self.semestres[sem["formsemestre_id"]] = sem annee_apogee = str( annee_scolaire_debut(sem["annee_debut"], sem["mois_debut_ord"]) ) self.set_indicatif(sem["formsemestre_id"], True) for etape in sem["etapes"]: self.add_etape(etape.etape_vdi, annee_apogee) def add_etape(self, etape_str, annee_apogee): """ Prise en compte d'une étape apogée :param etape_str: La clé de l'étape à prendre en compte :param annee_apogee: l'année de l'étape à prendre en compte :return: None """ if etape_str != "": key_etape = etape_to_key(annee_apogee, etape_str) if key_etape not in self.etapes: self.etapes.append(key_etape) self.set_indicatif( key_etape, False ) # ajout de la colonne/indicatif supplémentaire def compute_key_etu(self, nip, etudid): """ Calcul de la clé étudiant: * Le nip si il existe * sinon l'identifiant ScoDoc Tient à jour le dictionnaire key_etu (référentiel des étudiants) La problèmatique est de gérer toutes les anomalies possibles: - étudiant sans nip, - plusieurs étudiants avec le même nip, - etc. :param nip: le nip de l'étudiant :param etudid: l'identifiant ScoDoc :return: L'identifiant unique de l'étudiant """ if nip not in self.keys_etu: self.keys_etu[nip] = [] if etudid not in self.keys_etu[nip]: if etudid is None: if len(self.keys_etu[nip]) == 1: etudid = self.keys_etu[nip][0] else: # nip non trouvé ou utilisé par plusieurs étudiants self.keys_etu[nip].append(None) else: self.keys_etu[nip].append(etudid) return nip, etudid def register_etud_apogee(self, etud, etape): """ Enregistrement des données de l'étudiant par rapport à apogée. L'étudiant peut avoir été déjà enregistré auparavant (par exemple connu par son semestre) Dans ce cas, on ne met à jour que son association à l'étape apogée :param etud: les données étudiant :param etape: l'étape apogée :return: """ nip = etud["nip"] key_etu = self.compute_key_etu(nip, None) if key_etu not in self.etudiants: data = DataEtudiant(nip) data.set_apogee(etud) data.add_etape(etape) self.etudiants[key_etu] = data else: self.etudiants[key_etu].set_apogee(etud) self.etudiants[key_etu].add_etape(etape) return key_etu def register_etud_scodoc(self, etud: dict, sem: dict): """ Enregistrement de l'étudiant par rapport à son semestre :param etud: Les données de l'étudiant :param semestre: Le semestre où il est à enregistrer :return: la clé unique pour cet étudiant """ nip = etud["code_nip"] etudid = etud["etudid"] key_etu = self.compute_key_etu(nip, etudid) if key_etu not in self.etudiants: data = DataEtudiant(nip, etudid) data.set_scodoc(etud) data.add_semestre(sem) self.etudiants[key_etu] = data else: self.etudiants[key_etu].add_semestre(sem) return key_etu def load_listes(self): """ Inventaire complet des étudiants: * Pour tous les semestres d'abord * Puis pour toutes les étapes :return: None """ for formsemestre_id, sem in self.semestres.items(): etuds = sem["etuds"] self.etu_semestre[formsemestre_id] = set() for etud in etuds: key_etu = self.register_etud_scodoc(etud, formsemestre_id) self.etu_semestre[formsemestre_id].add(key_etu) for key_etape in self.etapes: annee_apogee, etapestr = key_to_values(key_etape) self.etu_etapes[key_etape] = set() for etud in get_inscrits_etape(etapestr, annee_apogee): key_etu = self.register_etud_apogee(etud, key_etape) self.etu_etapes[key_etape].add(key_etu) def dispatch(self): """ Réparti l'ensemble des étudiants selon les lignes (semestres) et les colonnes (étapes). :return: None """ # Initialisation des cumuls self.repartition[ROW_CUMUL, COL_CUMUL] = 0 self.repartition[PAS_DE_SEMESTRE, COL_CUMUL] = 0 self.repartition[PLUSIEURS_SEMESTRES, COL_CUMUL] = 0 self.repartition[ROW_CUMUL, PAS_DE_NIP] = 0 self.repartition[ROW_CUMUL, PAS_D_ETAPE] = 0 self.repartition[ROW_CUMUL, PLUSIEURS_ETAPES] = 0 for semestre in self.semestres: self.repartition[self.indicatifs[semestre], COL_CUMUL] = 0 for key_etape in self.etapes: self.repartition[ROW_CUMUL, self.indicatifs[key_etape]] = 0 # recherche des nip identiques for nip, keys_etu_nip in self.keys_etu.items(): if nip != "": nbnips = len(keys_etu_nip) if nbnips > 1: for i, etudid in enumerate(keys_etu_nip): data_etu = self.etudiants[nip, etudid] data_etu.add_tag(NIP_NON_UNIQUE) data_etu.nip = data_etu.nip + f" ({i+1}/{nbnips})" self.inc_tag_count(NIP_NON_UNIQUE) for nip, keys_etu_nip in self.keys_etu.items(): for etudid in keys_etu_nip: key_etu = (nip, etudid) data_etu = self.etudiants[key_etu] ind_col = "-" ind_row = "-" # calcul de la colonne if len(data_etu.etapes) == 1: ind_col = self.indicatifs[list(data_etu.etapes)[0]] elif nip == "": data_etu.add_tag(FLAG[PAS_DE_NIP]) ind_col = PAS_DE_NIP elif len(data_etu.etapes) == 0: self.etudiants[key_etu].add_tag(FLAG[PAS_D_ETAPE]) ind_col = PAS_D_ETAPE if len(data_etu.etapes) > 1: data_etu.add_tag(FLAG[PLUSIEURS_ETAPES]) ind_col = PLUSIEURS_ETAPES if len(data_etu.semestres) == 1: ind_row = self.indicatifs[list(data_etu.semestres)[0]] elif len(data_etu.semestres) > 1: data_etu.add_tag(FLAG[PLUSIEURS_SEMESTRES]) ind_row = PLUSIEURS_SEMESTRES elif len(data_etu.semestres) < 1: self.etudiants[key_etu].add_tag(FLAG[PAS_DE_SEMESTRE]) ind_row = PAS_DE_SEMESTRE data_etu.set_ind_col(ind_col) data_etu.set_ind_row(ind_row) self._inc_count(ind_row, ind_col) self.inc_tag_count(ind_row) self.inc_tag_count(ind_col) def html_diagnostic(self): """ affichage de l'html :return: Le code html à afficher """ self.load_listes() # chargement des données self.dispatch() # analyse et répartition # calcul de la liste des colonnes et des lignes de la table des effectifs self.all_rows_str = "'" + ",".join(["." + r for r in self.all_rows_ind]) + "'" self.all_cols_str = "'" + ",".join(["." + c for c in self.all_cols_ind]) + "'" H = [ """
Tableau des effectifs """, self._diagtable(), """
""", self.display_tags(), """
Liste des étudiants """, entete_liste_etudiant(), self.table_effectifs(), """
""", _help(), ] return "\n".join(H) def _inc_count(self, ind_row, ind_col): if (ind_row, ind_col) not in self.repartition: self.repartition[ind_row, ind_col] = 0 self.repartition[ind_row, ind_col] += 1 self.repartition[ROW_CUMUL, ind_col] += 1 self.repartition[ind_row, COL_CUMUL] += 1 self.repartition[ROW_CUMUL, COL_CUMUL] += 1 def _get_count(self, ind_row, ind_col): if (ind_row, ind_col) in self.repartition: count = self.repartition[ind_row, ind_col] if count > 1: comptage = f"({count} étudiants)" else: comptage = "(1 étudiant)" else: count = 0 return "" # Ajoute l'appel à la routine javascript de filtrage (apo_semset_maq_status.js # signature: # function show_css(elt, all_rows, all_cols, row, col, precision) # elt: le lien cliqué # all_rows: la liste de toutes les lignes existantes dans le tableau répartition # (exemple: ".Rb,.R1,.R2,.R3") # all_cols: la liste de toutes les colonnes existantes dans le tableau répartition # (exemple: ".Ca,.C1,.C2,.C3") # row: la ligne sélectionnée (sélecteur css) (expl: ".R1") # ; '*' si pas de sélection sur la ligne # col: la (les) colonnes sélectionnées (sélecteur css) (exple: ".C2") # ; '*' si pas de sélection sur colonne # precision: ajout sur le titre (en général, le nombre d'étudiant) # filtre_row: explicitation du filtre ligne éventuelle # filtre_col: explicitation du filtre colonne évnetuelle if ind_row == ROW_CUMUL and ind_col == COL_CUMUL: javascript = f"""doFiltrage({self.all_rows_str}, {self.all_cols_str}, '*', '*', '{comptage}', '', '' );""" elif ind_row == ROW_CUMUL: javascript = f"""doFiltrage({self.all_rows_str}, {self.all_cols_str}, '*', '.{ind_col}', '{comptage}', '', '{json.dumps(self.titres[ind_col].replace("
", " / "))[1:-1]}' );""" elif ind_col == COL_CUMUL: javascript = f"""doFiltrage({self.all_rows_str}, {self.all_cols_str}, '.{ind_row}', '*', ' ({count} étudiants)', '{json.dumps(self.titres[ind_row])[1:-1]}', '' );""" else: javascript = f"""doFiltrage({self.all_rows_str}, {self.all_cols_str}, '.{ind_row}', '.{ind_col}', '{comptage}', '{json.dumps(self.titres[ind_row])[1:-1]}', '{json.dumps(self.titres[ind_col].replace("
", " / "))[1:-1]}' );""" return f"""{count}""" def _diagtable(self) -> str: """Table avec les semestres et les effectifs""" H = [] liste_semestres = sorted(self.semestres.keys()) liste_etapes = [] for key_etape in self.etapes: liste_etapes.append(key_etape) liste_etapes.sort(key=lambda key: etape_to_col(key_etape)) col_ids = [] if PAS_DE_NIP in self.tag_count: col_ids.append(PAS_DE_NIP) if PAS_D_ETAPE in self.tag_count: col_ids.append(PAS_D_ETAPE) if PLUSIEURS_ETAPES in self.tag_count: col_ids.append(PLUSIEURS_ETAPES) self.titres["row_title"] = "Semestre" self.titres[PAS_DE_NIP] = "Hors Apogée (" + FLAG[PAS_DE_NIP] + ")" self.titres[PAS_D_ETAPE] = "Sans étape (" + FLAG[PAS_D_ETAPE] + ")" self.titres[PLUSIEURS_ETAPES] = ( "Plusieurs etapes (" + FLAG[PLUSIEURS_ETAPES] + ")" ) for key_etape in liste_etapes: col_id = self.indicatifs[key_etape] col_ids.append(col_id) self.titres[col_id] = "%s
%s" % key_to_values(key_etape) col_ids.append(COL_CUMUL) self.titres[COL_CUMUL] = "Total
semestre" rows = [] for semestre in liste_semestres: ind_row = self.indicatifs[semestre] self.titres[ind_row] = ( "%(titre_num)s (%(formsemestre_id)s)" % self.semestres[semestre] ) row = { "row_title": self.link_semestre(semestre), PAS_DE_NIP: self._get_count(ind_row, PAS_DE_NIP), PAS_D_ETAPE: self._get_count(ind_row, PAS_D_ETAPE), PLUSIEURS_ETAPES: self._get_count(ind_row, PLUSIEURS_ETAPES), COL_CUMUL: self._get_count(ind_row, COL_CUMUL), "_css_row_class": ind_row, } for key_etape in liste_etapes: ind_col = self.indicatifs[key_etape] row[ind_col] = self._get_count(ind_row, ind_col) rows.append(row) if PAS_DE_SEMESTRE in self.tag_count: row = { "row_title": "Hors semestres (" + FLAG[PAS_DE_SEMESTRE] + ")", PAS_DE_NIP: "", PAS_D_ETAPE: "", PLUSIEURS_ETAPES: "", COL_CUMUL: self._get_count(PAS_DE_SEMESTRE, COL_CUMUL), "_css_row_class": PAS_DE_SEMESTRE, } for key_etape in liste_etapes: ind_col = self.indicatifs[key_etape] row[ind_col] = self._get_count(PAS_DE_SEMESTRE, ind_col) rows.append(row) if PLUSIEURS_SEMESTRES in self.tag_count: row = { "row_title": "Plusieurs semestres (" + FLAG[PLUSIEURS_SEMESTRES] + ")", PAS_DE_NIP: "", PAS_D_ETAPE: "", PLUSIEURS_ETAPES: "", COL_CUMUL: self._get_count(PLUSIEURS_SEMESTRES, COL_CUMUL), "_css_row_class": PLUSIEURS_SEMESTRES, } for key_etape in liste_etapes: ind_col = self.indicatifs[key_etape] row[ind_col] = self._get_count(PLUSIEURS_SEMESTRES, ind_col) rows.append(row) row = { "row_title": "Total", PAS_DE_NIP: self._get_count(ROW_CUMUL, PAS_DE_NIP), PAS_D_ETAPE: self._get_count(ROW_CUMUL, PAS_D_ETAPE), PLUSIEURS_ETAPES: self._get_count(ROW_CUMUL, PLUSIEURS_ETAPES), COL_CUMUL: self._get_count(ROW_CUMUL, COL_CUMUL), "_css_row_class": COL_CUMUL, } for key_etape in liste_etapes: ind_col = self.indicatifs[key_etape] row[ind_col] = self._get_count(ROW_CUMUL, ind_col) rows.append(row) H.append( GenTable( rows, col_ids, self.titres, html_class="repartition", html_with_td_classes=True, ).gen(fmt="html") ) return "\n".join(H) def display_tags(self): H = [] if NIP_NON_UNIQUE in self.tag_count: H.append("

Anomalies

") javascript = "show_tag(%s, %s, '%s');" % ( self.all_rows_str, self.all_cols_str, NIP_NON_UNIQUE, ) H.append( f"""Code(s) nip) partagé(s) par {self.tag_count[NIP_NON_UNIQUE]} étudiants
""" ) return "\n".join(H) @staticmethod def link_etu(etudid, nom): return '%s' % ( url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), nom, ) def link_semestre(self, semestre, short=False): if short: return ( '%(' "formsemestre_id)s " % self.semestres[semestre] ) else: return ( '%(titre_num)s' " %(mois_debut)s - %(mois_fin)s)" % self.semestres[semestre] ) def table_effectifs(self): H = [] col_ids = ["tag", "etudiant", "prenom", "nip", "semestre", "apogee", "annee"] titles = { "tag": "Etat", "etudiant": "Nom", "prenom": "Prenom", "nip": "code nip", "semestre": "semestre", "annee": "année", "apogee": "etape", } rows = [] for data_etu in sorted( list(self.etudiants.values()), key=lambda etu: etu.get_identity() ): nip = data_etu.nip etudid = data_etu.etudid if data_etu.data_scodoc is None: nom = data_etu.data_apogee["nom"] prenom = data_etu.data_apogee["prenom"] link = nom else: nom = data_etu.data_scodoc["nom"] prenom = data_etu.data_scodoc["prenom"] link = self.link_etu(etudid, nom) tag = ", ".join([tag for tag in sorted(data_etu.tags)]) semestre = "
".join( [self.link_semestre(sem, True) for sem in data_etu.semestres] ) annees = "
".join([etape[0] for etape in data_etu.etapes]) etapes = "
".join([etape[1] for etape in data_etu.etapes]) classe = data_etu.ind_row + data_etu.ind_col if NIP_NON_UNIQUE in data_etu.tags: classe += " " + NIP_NON_UNIQUE row = { "tag": tag, "etudiant": link, "prenom": prenom.capitalize(), "nip": nip, "semestre": semestre, "annee": annees, "apogee": etapes, "_css_row_class": classe, } rows.append(row) H.append( GenTable( rows, col_ids, titles, table_id="detail", html_class="table_leftalign", html_sortable=True, ).gen(fmt="html") ) return "\n".join(H) def etape_to_key(annee_apogee, etape_str): return annee_apogee, etape_str def key_to_values(key_etape): return key_etape def etape_to_col(key_etape): return "%s@%s" % key_etape