340 lines
12 KiB
Python
340 lines
12 KiB
Python
# -*- 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
|