ScoDoc/app/pe/pe_comp.py

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