# -*- 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 import pandas as pd from flask import g import app.scodoc.sco_utils as scu from app.models import FormSemestre from app.pe.rcss.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 def tri_semestres_par_rang(cosemestres: dict[int, FormSemestre]): """Partant d'un dictionnaire de cosemestres, les tri par rang (semestre_id) dans un dictionnaire {rang: [liste des semestres du dit rang]}""" cosemestres_tries = {} for sem in cosemestres.values(): cosemestres_tries[sem.semestre_id] = cosemestres_tries.get( sem.semestre_id, [] ) + [sem] return cosemestres_tries def find_index_and_columns_communs( df1: pd.DataFrame, df2: pd.DataFrame ) -> (list, list): """Partant de 2 DataFrames ``df1`` et ``df2``, renvoie les indices de lignes et de colonnes, communes aux 2 dataframes Args: df1: Un dataFrame df2: Un dataFrame Returns: Le tuple formé par la liste des indices de lignes communs et la liste des indices de colonnes communes entre les 2 dataFrames """ indices1 = df1.index indices2 = df2.index indices_communs = list(df1.index.intersection(df2.index)) colonnes1 = df1.columns colonnes2 = df2.columns colonnes_communes = list(set(colonnes1) & set(colonnes2)) return indices_communs, colonnes_communes def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre: """Renvoie le dernier semestre en **date de fin** d'un dictionnaire de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``. Args: semestres: Un dictionnaire de semestres Return: Le FormSemestre du semestre le plus récent """ if semestres: fid_dernier_semestre = list(semestres.keys())[0] dernier_semestre: FormSemestre = semestres[fid_dernier_semestre] for fid in semestres: if semestres[fid].date_fin > dernier_semestre.date_fin: dernier_semestre = semestres[fid] return dernier_semestre return None