forked from ScoDoc/ScoDoc
1721 lines
51 KiB
Python
1721 lines
51 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
|
|
#
|
|
##############################################################################
|
|
|
|
|
|
"""Common definitions"""
|
|
|
|
import base64
|
|
import bisect
|
|
import collections
|
|
import datetime
|
|
from enum import IntEnum, Enum
|
|
import io
|
|
import json
|
|
from hashlib import md5
|
|
import numbers
|
|
import os
|
|
import re
|
|
from shutil import get_terminal_size
|
|
import _thread
|
|
import time
|
|
import unicodedata
|
|
import urllib
|
|
from urllib.parse import urlparse, parse_qsl, urlunparse, urlencode
|
|
|
|
import numpy as np
|
|
from PIL import Image as PILImage
|
|
import pydot
|
|
import requests
|
|
|
|
from pytz import timezone
|
|
|
|
|
|
import flask
|
|
from flask import g, render_template, request, Response
|
|
from flask import flash, make_response
|
|
from flask_json import json_response
|
|
from werkzeug.http import HTTP_STATUS_CODES
|
|
|
|
from config import Config
|
|
from app import log, ScoDocJSONEncoder
|
|
|
|
from app.forms.multiselect import MultiSelect
|
|
|
|
from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL
|
|
from app.scodoc.sco_exceptions import ScoValueError
|
|
from app.scodoc import sco_xml
|
|
import sco_version
|
|
|
|
# En principe, aucun champ text ne devrait excéder cette taille
|
|
MAX_TEXT_LEN = 64 * 1024
|
|
|
|
# le répertoire static, lié à chaque release pour éviter les problèmes de caches
|
|
STATIC_DIR = (
|
|
os.environ.get("SCRIPT_NAME", "") + "/ScoDoc/static/links/" + sco_version.SCOVERSION
|
|
)
|
|
|
|
# Attention: suppose que la timezone utilisée par postgresql soit la même !
|
|
TIME_ZONE = timezone("/".join(os.path.realpath("/etc/localtime").split("/")[-2:]))
|
|
"La timezone du serveur"
|
|
|
|
# ----- CIVILITE ETUDIANTS
|
|
CIVILITES = {"M": "M.", "F": "Mme", "X": ""}
|
|
CIVILITES_ETAT_CIVIL = {"M": "M.", "F": "Mme"}
|
|
# Si l'état civil reconnait le genre neutre (X),:
|
|
# CIVILITES_ETAT_CIVIL = CIVILITES
|
|
|
|
# ----- CALCUL ET PRESENTATION DES NOTES
|
|
NOTES_PRECISION = 1e-4 # evite eventuelles erreurs d'arrondis
|
|
NOTES_MIN = 0.0 # valeur minimale admise pour une note (sauf malus, dans [-20, 20])
|
|
NOTES_MAX = 1000.0
|
|
NOTES_ABSENCE = -999.0 # absences dans les DataFrames, NULL en base
|
|
NOTES_NEUTRALISE = -1000.0 # notes non prises en comptes dans moyennes
|
|
NOTES_SUPPRESS = -1001.0 # note a supprimer
|
|
NOTES_ATTENTE = -1002.0 # note "en attente" (se calcule comme une note neutralisee)
|
|
|
|
NO_NOTE_STR = "-" # contenu des cellules de tableaux html sans notes
|
|
|
|
# ---- CODES INSCRIPTION AUX SEMESTRES
|
|
# (champ etat de FormSemestreInscription)
|
|
INSCRIT = "I"
|
|
DEMISSION = "D"
|
|
DEF = "DEF"
|
|
ETATS_INSCRIPTION = {
|
|
INSCRIT: "Inscrit",
|
|
DEMISSION: "Démission",
|
|
DEF: "Défaillant",
|
|
}
|
|
ETATS_INSCRIPTION_SHORT = {
|
|
INSCRIT: "I",
|
|
DEMISSION: "DEM",
|
|
DEF: "DEF",
|
|
}
|
|
|
|
|
|
def convert_fr_date(
|
|
date_str: str | datetime.datetime, allow_iso=True
|
|
) -> datetime.datetime:
|
|
"""Converti une date saisie par un humain français avant 2070
|
|
en un objet datetime.
|
|
12/2/1972 => 1972-02-12, 12/2/72 => 1972-02-12, mais 12/2/24 => 2024-02-12
|
|
Le pivot est 70.
|
|
ScoValueError si date invalide.
|
|
"""
|
|
if isinstance(date_str, datetime.datetime):
|
|
return date_str
|
|
try:
|
|
return datetime.datetime.strptime(date_str, DATE_FMT)
|
|
except ValueError:
|
|
# Try to add century ?
|
|
m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d\d)$", date_str)
|
|
if m:
|
|
year = int(m.group(3))
|
|
if year < 70:
|
|
year += 2000
|
|
else:
|
|
year += 1900
|
|
try:
|
|
return datetime.datetime.strptime(
|
|
f"{m.group(1)}/{m.group(2)}/{year}", DATE_FMT
|
|
)
|
|
except ValueError:
|
|
pass
|
|
if allow_iso:
|
|
try:
|
|
return datetime.datetime.fromisoformat(date_str)
|
|
except ValueError as exc:
|
|
raise ScoValueError("Date (j/m/a or ISO) invalide") from exc
|
|
raise ScoValueError("Date (j/m/a) invalide")
|
|
|
|
|
|
def print_progress_bar(
|
|
iteration,
|
|
total,
|
|
prefix="",
|
|
suffix="",
|
|
finish_msg="",
|
|
decimals=1,
|
|
length=100,
|
|
fill="█",
|
|
autosize=False,
|
|
):
|
|
"""
|
|
Affiche une progress bar à un point donné (mettre dans une boucle pour rendre dynamique)
|
|
@params:
|
|
iteration - Required : index du point donné (Int)
|
|
total - Required : nombre total avant complétion (eg: len(List))
|
|
prefix - Optional : Préfix -> écrit à gauche de la barre (Str)
|
|
suffix - Optional : Suffix -> écrit à droite de la barre (Str)
|
|
decimals - Optional : nombres de chiffres après la virgule (Int)
|
|
length - Optional : taille de la barre en nombre de caractères (Int)
|
|
fill - Optional : charactère de remplissange de la barre (Str)
|
|
autosize - Optional : Choisir automatiquement la taille de la barre
|
|
en fonction du terminal (Bool)
|
|
"""
|
|
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
|
|
color = TerminalColor.RED
|
|
if 50 >= float(percent) > 25:
|
|
color = TerminalColor.MAGENTA
|
|
if 75 >= float(percent) > 50:
|
|
color = TerminalColor.BLUE
|
|
if 90 >= float(percent) > 75:
|
|
color = TerminalColor.CYAN
|
|
if 100 >= float(percent) > 90:
|
|
color = TerminalColor.GREEN
|
|
styling = f"{prefix} |{fill}| {percent}% {suffix}"
|
|
if autosize:
|
|
cols, _ = get_terminal_size(fallback=(length, 1))
|
|
length = cols - len(styling)
|
|
filled_length = int(length * iteration // total)
|
|
pg_bar = fill * filled_length + "-" * (length - filled_length)
|
|
print(f"\r{color}{styling.replace(fill, pg_bar)}{TerminalColor.RESET}", end="\r")
|
|
# Affiche une nouvelle ligne vide
|
|
if iteration == total:
|
|
print(f"\n{finish_msg}")
|
|
|
|
|
|
class TerminalColor:
|
|
"""Ensemble de couleur pour terminaux"""
|
|
|
|
BLUE = "\033[94m"
|
|
CYAN = "\033[96m"
|
|
GREEN = "\033[92m"
|
|
MAGENTA = "\033[95m"
|
|
RED = "\033[91m"
|
|
RESET = "\033[0m"
|
|
|
|
|
|
class BiDirectionalEnum(Enum):
|
|
"""Permet la recherche inverse d'un enum
|
|
Condition : les clés et les valeurs doivent être uniques
|
|
les clés doivent être en MAJUSCULES
|
|
=> (respect de la convention des constantes)
|
|
"""
|
|
|
|
@classmethod
|
|
def contains(cls, attr: str):
|
|
"""Vérifie sur un attribut existe dans l'enum"""
|
|
|
|
# Existe dans la classe parent de Enum (EnumType)
|
|
# pylint: disable-next=no-member
|
|
return attr.upper() in cls._member_names_
|
|
|
|
@classmethod
|
|
def all(cls, keys=True) -> tuple[str | object]:
|
|
"""Retourne toutes les clés de l'enum (en minuscules) ou les valeurs"""
|
|
return (
|
|
tuple(
|
|
k.lower()
|
|
# pylint: disable-next=no-member
|
|
for k in cls._member_names_
|
|
) # renvoie les clés en minuscules
|
|
if keys
|
|
else tuple(cls._value2member_map_.keys()) # renvoie les valeurs
|
|
)
|
|
|
|
@classmethod
|
|
def get(cls, attr: str, default: any = None):
|
|
"""Récupère une valeur à partir de son attribut"""
|
|
val = None
|
|
try:
|
|
val = cls[attr.upper()]
|
|
except (KeyError, AttributeError):
|
|
val = default
|
|
return val
|
|
|
|
@classmethod
|
|
def inverse(cls):
|
|
"""Retourne un dictionnaire représentant la map inverse de l'Enum"""
|
|
return cls._value2member_map_
|
|
|
|
|
|
class EtatAssiduite(int, BiDirectionalEnum):
|
|
"""Code des états d'assiduité"""
|
|
|
|
# Stockés en BD ne pas modifier
|
|
|
|
PRESENT = 0
|
|
RETARD = 1
|
|
ABSENT = 2
|
|
|
|
def version_lisible(self) -> str:
|
|
"""Retourne une version lisible des états d'assiduités
|
|
Est utilisé pour les vues.
|
|
"""
|
|
return {
|
|
EtatAssiduite.PRESENT: "Présence",
|
|
EtatAssiduite.ABSENT: "Absence",
|
|
EtatAssiduite.RETARD: "Retard",
|
|
}.get(self, "")
|
|
|
|
def e(self) -> str:
|
|
"""e si la version libile est féminine"""
|
|
return {
|
|
EtatAssiduite.PRESENT: "e",
|
|
EtatAssiduite.ABSENT: "e",
|
|
EtatAssiduite.RETARD: "",
|
|
}.get(self, "")
|
|
|
|
|
|
class EtatJustificatif(int, BiDirectionalEnum):
|
|
"""Code des états des justificatifs"""
|
|
|
|
# Stockés en BD ne pas modifier
|
|
|
|
VALIDE = 0
|
|
NON_VALIDE = 1
|
|
ATTENTE = 2
|
|
MODIFIE = 3
|
|
|
|
def version_lisible(self) -> str:
|
|
"""Retourne une version lisible des états de justificatifs
|
|
Est utilisé pour les vues.
|
|
"""
|
|
return {
|
|
EtatJustificatif.VALIDE: "valide",
|
|
EtatJustificatif.ATTENTE: "soumis",
|
|
EtatJustificatif.MODIFIE: "modifié",
|
|
EtatJustificatif.NON_VALIDE: "invalide",
|
|
}.get(self, "")
|
|
|
|
@classmethod
|
|
def is_valid_etat(cls, etat: int) -> bool:
|
|
"True if etat is valid"
|
|
return etat in cls._value2member_map_
|
|
|
|
|
|
class NonWorkDays(int, BiDirectionalEnum):
|
|
"""Correspondance entre les jours et les numéros de jours"""
|
|
|
|
LUN = 0
|
|
MAR = 1
|
|
MER = 2
|
|
JEU = 3
|
|
VEN = 4
|
|
SAM = 5
|
|
DIM = 6
|
|
|
|
@classmethod
|
|
def get_all_non_work_days(
|
|
cls, formsemestre_id: int = None, dept_id: int = None
|
|
) -> list["NonWorkDays"]:
|
|
"""
|
|
get_all_non_work_days Récupère la liste des non workdays
|
|
(str) depuis les préférences
|
|
puis renvoie une liste BiDirectionnalEnum<int> NonWorkDays
|
|
|
|
Example:
|
|
non_work_days : list[NonWorkDays] =
|
|
NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
|
|
if datetime.datetime.now().weekday() in non_work_days:
|
|
print("Aujourd'hui est un jour non travaillé")
|
|
|
|
Args:
|
|
formsemestre_id (int, optional): id d'un formsemestre . Defaults to None.
|
|
dept_id (int, optional): id d'un départment. Defaults to None.
|
|
|
|
Returns:
|
|
list[NonWorkDays]: La liste des NonWorkDays en version BiDirectionnalEnum<int>
|
|
"""
|
|
# Import circulaire
|
|
# pylint: disable=import-outside-toplevel
|
|
from app.scodoc import sco_preferences
|
|
|
|
return [
|
|
cls.get(day.strip())
|
|
for day in sco_preferences.get_preference(
|
|
"non_travail", formsemestre_id=formsemestre_id, dept_id=dept_id
|
|
).split(",")
|
|
]
|
|
|
|
|
|
def is_iso_formated(date: str, convert=False) -> bool | datetime.datetime | None:
|
|
"""
|
|
Vérifie si une date est au format iso
|
|
|
|
Retourne un booléen Vrai (ou un objet Datetime si convert = True)
|
|
si l'objet est au format iso
|
|
|
|
Retourne Faux si l'objet n'est pas au format et convert = False
|
|
|
|
Retourne None sinon
|
|
"""
|
|
|
|
try:
|
|
date: datetime.datetime = datetime.datetime.fromisoformat(date)
|
|
return date if convert else True
|
|
except (ValueError, TypeError):
|
|
return None if convert else False
|
|
|
|
|
|
def localize_datetime(date: datetime.datetime) -> datetime.datetime:
|
|
"""Transforme une date sans offset en une date avec offset
|
|
Tente de mettre l'offset de la timezone du serveur (ex : UTC+1)
|
|
Si erreur, mettra l'offset UTC
|
|
"""
|
|
|
|
new_date: datetime.datetime = date
|
|
if new_date.tzinfo is None:
|
|
try:
|
|
new_date = TIME_ZONE.localize(date)
|
|
except OverflowError:
|
|
new_date = timezone("UTC").localize(date)
|
|
return new_date
|
|
|
|
|
|
def get_local_timezone_offset() -> str:
|
|
"""Récupère l'offset de la timezone du serveur, sous la forme
|
|
"+HH:MM"
|
|
"""
|
|
local_time = datetime.datetime.now().astimezone()
|
|
utc_offset = local_time.utcoffset()
|
|
total_seconds = int(utc_offset.total_seconds())
|
|
offset_hours = total_seconds // 3600
|
|
offset_minutes = (abs(total_seconds) % 3600) // 60
|
|
offset_sign = "+" if offset_hours >= 0 else "-"
|
|
offset_str = f"{offset_sign}{abs(offset_hours):02d}:{offset_minutes:02d}"
|
|
return offset_str
|
|
|
|
|
|
def is_period_overlapping(
|
|
periode: tuple[datetime.datetime, datetime.datetime],
|
|
interval: tuple[datetime.datetime, datetime.datetime],
|
|
bornes: bool = True,
|
|
) -> bool:
|
|
"""
|
|
Vérifie si la période et l'interval s'intersectent
|
|
si strict == True : les extrémitées ne comptes pas
|
|
Retourne Vrai si c'est le cas, faux sinon.
|
|
Attention: offset-aware datetimes
|
|
"""
|
|
p_deb, p_fin = periode
|
|
i_deb, i_fin = interval
|
|
|
|
if bornes:
|
|
return p_deb <= i_fin and p_fin >= i_deb
|
|
return p_deb < i_fin and p_fin > i_deb
|
|
|
|
|
|
class AssiduitesMetrics:
|
|
"""Labels associés à la métrique de l'assiduité"""
|
|
|
|
SHORT: list[str] = ["1/2 J.", "J.", "H."] # forme stockée en pref.
|
|
LONG: list[str] = ["Demi-journée", "Journée", "Heure"]
|
|
TAG: list[str] = ["demi", "journee", "heure"]
|
|
|
|
@classmethod
|
|
def short_to_str(cls, short_metric: str, lower_plural=False) -> str:
|
|
"""La forme longue à afficher à partir du code short, stocké en préf.
|
|
Raise ValueError if invalid arg."""
|
|
idx = cls.SHORT.index(short_metric)
|
|
return cls.LONG[idx].lower() + "s" if lower_plural else cls.LONG[idx].lower()
|
|
|
|
|
|
def translate_assiduites_metric(metric, inverse=True, short=True) -> str:
|
|
"""
|
|
SHORT[true] : "J." "H." "N." "1/2 J."
|
|
SHORT[false] : "Journée" "Heure" "Nombre" "Demi-Journée"
|
|
|
|
inverse[false] : "demi" -> "1/2 J."
|
|
inverse[true] : "1/2 J." -> "demi"
|
|
|
|
Args:
|
|
metric (str): la métrique à traduire
|
|
inverse (bool, optional). Defaults to True.
|
|
short (bool, optional). Defaults to True.
|
|
|
|
Returns:
|
|
str: la métrique traduite
|
|
"""
|
|
index: int = None
|
|
if not inverse:
|
|
try:
|
|
index = AssiduitesMetrics.TAG.index(metric)
|
|
return (
|
|
AssiduitesMetrics.SHORT[index]
|
|
if short
|
|
else AssiduitesMetrics.LONG[index]
|
|
)
|
|
except ValueError:
|
|
return None
|
|
|
|
try:
|
|
index = (
|
|
AssiduitesMetrics.SHORT.index(metric)
|
|
if short
|
|
else AssiduitesMetrics.LONG.index(metric)
|
|
)
|
|
return AssiduitesMetrics.TAG[index]
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
# Types de modules
|
|
class ModuleType(IntEnum):
|
|
"""Code des types de module."""
|
|
|
|
# Stockés en BD dans Module.module_type: ne pas modifier ces valeurs
|
|
STANDARD = 0
|
|
MALUS = 1
|
|
RESSOURCE = 2 # BUT
|
|
SAE = 3 # BUT
|
|
|
|
@classmethod
|
|
def get_abbrev(cls, code) -> str:
|
|
"""Abbréviation décrivant le type de module à partir du code integer:
|
|
"mod", "malus", "res", "sae"
|
|
(utilisées pour style CSS)
|
|
"""
|
|
return {
|
|
ModuleType.STANDARD: "mod",
|
|
ModuleType.MALUS: "malus",
|
|
ModuleType.RESSOURCE: "res",
|
|
ModuleType.SAE: "sae",
|
|
}.get(code, "???")
|
|
|
|
|
|
MODULE_TYPE_NAMES = {
|
|
ModuleType.STANDARD: "Module",
|
|
ModuleType.MALUS: "Malus",
|
|
ModuleType.RESSOURCE: "Ressource",
|
|
ModuleType.SAE: "SAÉ",
|
|
None: "Module",
|
|
}
|
|
|
|
PARTITION_PARCOURS = "Parcours"
|
|
|
|
MALUS_MAX = 20.0
|
|
MALUS_MIN = -20.0
|
|
|
|
APO_MISSING_CODE_STR = "----" # shown in HTML pages in place of missing code Apogée
|
|
EDIT_NB_ETAPES = 6 # Nombre max de codes étapes / semestre presentés dans l'UI
|
|
|
|
IT_SITUATION_MISSING_STR = (
|
|
"____" # shown on fiche_etud (devenir) in place of empty situation
|
|
)
|
|
|
|
RANG_ATTENTE_STR = "(attente)" # rang affiché sur bulletins quand notes en attente
|
|
|
|
# borne supérieure de chaque mention
|
|
NOTES_MENTIONS_TH = (
|
|
NOTES_TOLERANCE,
|
|
7.0,
|
|
10.0,
|
|
12.0,
|
|
14.0,
|
|
16.0,
|
|
18.0,
|
|
20.0 + NOTES_TOLERANCE,
|
|
)
|
|
NOTES_MENTIONS_LABS = (
|
|
"Nul",
|
|
"Faible",
|
|
"Insuffisant",
|
|
"Passable",
|
|
"Assez bien",
|
|
"Bien",
|
|
"Très bien",
|
|
"Excellent",
|
|
)
|
|
|
|
# Dates et années scolaires
|
|
# Ces dates "pivot" sont paramétrables dans les préférences générales
|
|
# on donne ici les valeurs par défaut.
|
|
# Les semestres commençant à partir du 1er août 20XX sont
|
|
# dans l'année scolaire 20XX
|
|
MONTH_DEBUT_ANNEE_SCOLAIRE = 8 # août
|
|
# Les semestres commençant à partir du 1er décembre
|
|
# sont "2eme période" (S_pair):
|
|
MONTH_DEBUT_PERIODE2 = MONTH_DEBUT_ANNEE_SCOLAIRE + 4
|
|
|
|
MONTH_NAMES_ABBREV = (
|
|
"Jan ",
|
|
"Fév ",
|
|
"Mars",
|
|
"Avr ",
|
|
"Mai ",
|
|
"Juin",
|
|
"Juil ",
|
|
"Août",
|
|
"Sept",
|
|
"Oct ",
|
|
"Nov ",
|
|
"Déc ",
|
|
)
|
|
|
|
MONTH_NAMES = (
|
|
"janvier",
|
|
"février",
|
|
"mars",
|
|
"avril",
|
|
"mai",
|
|
"juin",
|
|
"juillet",
|
|
"août",
|
|
"septembre",
|
|
"octobre",
|
|
"novembre",
|
|
"décembre",
|
|
)
|
|
DAY_NAMES = ("lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche")
|
|
|
|
TIME_FMT = "%Hh%M" # affichage des heures
|
|
DATE_FMT = "%d/%m/%Y" # affichage des dates
|
|
DATEATIME_FMT = DATE_FMT + " à " + TIME_FMT
|
|
DATETIME_FMT = DATE_FMT + " " + TIME_FMT
|
|
|
|
|
|
def fmt_note(
|
|
val, note_max=None, keep_numeric=False, fixed_precision_str=True
|
|
) -> str | float:
|
|
"""conversion note en str pour affichage dans tables HTML ou PDF.
|
|
Si keep_numeric, laisse les valeur numeriques telles quelles (pour export Excel)
|
|
Si fixed_precision_str (défaut), formatte sur 4 chiffres ("01.23"),
|
|
sinon utilise %g (chaine précision variable, pour les formulaires)
|
|
"""
|
|
if val is None or val == NOTES_ABSENCE:
|
|
return "ABS"
|
|
if val == NOTES_NEUTRALISE:
|
|
return "EXC" # excuse, note neutralise
|
|
if val == NOTES_ATTENTE:
|
|
return "ATT" # attente, note neutralisee
|
|
if val == NOTES_SUPPRESS:
|
|
return "SUPR" # pour les formulaires de saisie
|
|
if not isinstance(val, str):
|
|
if np.isnan(val):
|
|
return "~"
|
|
if (note_max is not None) and note_max > 0:
|
|
val = val * 20.0 / note_max
|
|
if keep_numeric:
|
|
return val
|
|
if fixed_precision_str:
|
|
s = f"{round(float(val), 2):2.2f}" # 2 chiffres apres la virgule
|
|
s = "0" * (5 - len(s)) + s # padding: 0 à gauche pour longueur 5: "12.34"
|
|
return s
|
|
return f"{val:g}"
|
|
|
|
return val.replace("NA", "-")
|
|
|
|
|
|
def fmt_coef(val):
|
|
"""Conversion valeur coefficient (float) en chaine"""
|
|
if val < 0.01:
|
|
return "%g" % val # unusually small value
|
|
return "%g" % round(val, 2)
|
|
|
|
|
|
def fmt_abs(val):
|
|
"""Conversion absences en chaine. val est une list [nb_abs_total, nb_abs_justifiees
|
|
=> NbAbs / Nb_justifiees
|
|
"""
|
|
return "%s / %s" % (val[0], val[1])
|
|
|
|
|
|
def isnumber(x):
|
|
"True if x is a number (int, float, etc.)"
|
|
return isinstance(x, numbers.Number)
|
|
|
|
|
|
def jsnan(x):
|
|
"if x is NaN, returns None"
|
|
if isinstance(x, numbers.Number) and np.isnan(x):
|
|
return None
|
|
return x
|
|
|
|
|
|
def join_words(*words):
|
|
words = [str(w).strip() for w in words if w is not None]
|
|
return " ".join([w for w in words if w])
|
|
|
|
|
|
def get_mention(moy):
|
|
"""Texte "mention" en fonction de la moyenne générale"""
|
|
try:
|
|
moy = float(moy)
|
|
except:
|
|
return ""
|
|
if moy > 0.0:
|
|
return NOTES_MENTIONS_LABS[bisect.bisect_right(NOTES_MENTIONS_TH, moy)]
|
|
else:
|
|
return ""
|
|
|
|
|
|
def group_by_key(d: dict, key) -> dict:
|
|
grouped = collections.defaultdict(lambda: [])
|
|
for e in d:
|
|
grouped[e[key]].append(e)
|
|
return grouped
|
|
|
|
|
|
# ----- Global lock for critical sections (except notes_tables caches)
|
|
GSL = _thread.allocate_lock() # Global ScoDoc Lock
|
|
|
|
SCODOC_DIR = Config.SCODOC_DIR
|
|
|
|
# ----- Repertoire "config" modifiable
|
|
# /opt/scodoc-data/config
|
|
SCODOC_CFG_DIR = os.path.join(Config.SCODOC_VAR_DIR, "config")
|
|
# ----- Version information
|
|
SCODOC_VERSION_DIR = os.path.join(SCODOC_CFG_DIR, "version")
|
|
# ----- Repertoire tmp : /opt/scodoc-data/tmp
|
|
SCO_TMP_DIR = os.path.join(Config.SCODOC_VAR_DIR, "tmp")
|
|
if not os.path.exists(SCO_TMP_DIR) and os.path.exists(Config.SCODOC_VAR_DIR):
|
|
os.mkdir(SCO_TMP_DIR, 0o755)
|
|
# ----- Les logos: /opt/scodoc-data/config/logos
|
|
SCODOC_LOGOS_DIR = os.path.join(SCODOC_CFG_DIR, "logos")
|
|
LOGOS_IMAGES_ALLOWED_TYPES = ("jpg", "jpeg", "png") # remind that PIL does not read pdf
|
|
LOGOS_DIR_PREFIX = "logos_"
|
|
LOGO_FILE_PREFIX = "logo_"
|
|
|
|
# forme générale des noms des fichiers logos/background:
|
|
# SCODOC_LOGO_DIR/LOGO_FILE_PREFIX<name>.<suffix> (fichier global) ou
|
|
# SCODOC_LOGO_DIR/LOGOS_DIR_PREFIX<dept_id>/LOGO_FILE_PREFIX<name>.<suffix> (fichier départemental)
|
|
|
|
# ----- Les outils distribués
|
|
SCO_TOOLS_DIR = os.path.join(Config.SCODOC_DIR, "tools")
|
|
|
|
|
|
# ----- Lecture du fichier de configuration
|
|
from app.scodoc import sco_config
|
|
from app.scodoc import sco_config_load
|
|
|
|
sco_config_load.load_local_configuration(SCODOC_CFG_DIR)
|
|
CONFIG = sco_config.CONFIG
|
|
if hasattr(CONFIG, "CODES_EXPL"):
|
|
CODES_EXPL.update(
|
|
CONFIG.CODES_EXPL
|
|
) # permet de customiser les explications de codes
|
|
|
|
if CONFIG.CUSTOM_HTML_HEADER:
|
|
CUSTOM_HTML_HEADER = open(CONFIG.CUSTOM_HTML_HEADER).read()
|
|
else:
|
|
CUSTOM_HTML_HEADER = ""
|
|
|
|
if CONFIG.CUSTOM_HTML_HEADER_CNX:
|
|
CUSTOM_HTML_HEADER_CNX = open(CONFIG.CUSTOM_HTML_HEADER_CNX).read()
|
|
else:
|
|
CUSTOM_HTML_HEADER_CNX = ""
|
|
|
|
if CONFIG.CUSTOM_HTML_FOOTER:
|
|
CUSTOM_HTML_FOOTER = open(CONFIG.CUSTOM_HTML_FOOTER).read()
|
|
else:
|
|
CUSTOM_HTML_FOOTER = ""
|
|
|
|
if CONFIG.CUSTOM_HTML_FOOTER_CNX:
|
|
CUSTOM_HTML_FOOTER_CNX = open(CONFIG.CUSTOM_HTML_FOOTER_CNX).read()
|
|
else:
|
|
CUSTOM_HTML_FOOTER_CNX = ""
|
|
|
|
SCO_ENCODING = "utf-8" # used by Excel, XML, PDF, ...
|
|
|
|
|
|
SCO_DEFAULT_SQL_USER = "scodoc" # should match Zope process UID
|
|
SCO_DEFAULT_SQL_PORT = "5432"
|
|
SCO_DEFAULT_SQL_USERS_CNX = "dbname=SCOUSERS port=%s" % SCO_DEFAULT_SQL_PORT
|
|
|
|
# Valeurs utilisées pour affichage seulement, pas de requetes ni de mails envoyés:
|
|
SCO_WEBSITE = "https://scodoc.org"
|
|
SCO_USER_MANUAL = "https://scodoc.org/GuideUtilisateur"
|
|
SCO_ANNONCES_WEBSITE = "https://scodoc.org/Contact"
|
|
SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr"
|
|
SCO_USERS_LIST = "notes@listes.univ-paris13.fr"
|
|
SCO_LISTS_URL = "https://scodoc.org/Contact"
|
|
SCO_DISCORD_ASSISTANCE = "https://discord.gg/ybw6ugtFsZ"
|
|
|
|
# Mails avec exceptions (erreurs) anormales envoyés à cette adresse:
|
|
# mettre '' pour désactiver completement l'envois de mails d'erreurs.
|
|
# (ces mails sont précieux pour corriger les erreurs, ne les désactiver que si
|
|
# vous avez de bonnes raisons de le faire: vous pouvez me contacter avant)
|
|
SCO_EXC_MAIL = "scodoc-exception@viennet.net"
|
|
|
|
# L'adresse du mainteneur (non utilisée automatiquement par ScoDoc: ne pas changer)
|
|
SCO_DEV_MAIL = "emmanuel.viennet@gmail.com" # SVP ne pas changer
|
|
|
|
# Adresse pour l'envoi des dumps (pour assistance technnique):
|
|
# ne pas changer (ou vous perdez le support)
|
|
SCO_DUMP_UP_URL = "https://scodoc.org/scodoc-installmgr/upload-dump"
|
|
SCO_UP2DATE = "https://scodoc.org/scodoc-installmgr/check_version"
|
|
SCO_BUG_REPORT_URL = "https://scodoc.org/scodoc-installmgr/report"
|
|
SCO_ORG_TIMEOUT = 180 # contacts scodoc.org
|
|
SCO_EXT_TIMEOUT = 180 # appels à des ressources extérieures (siret, ...)
|
|
SCO_TEST_API_TIMEOUT = 5 # pour tests unitaires API
|
|
CSV_FIELDSEP = ";"
|
|
CSV_LINESEP = "\n"
|
|
CSV_MIMETYPE = "text/comma-separated-values"
|
|
CSV_SUFFIX = ".csv"
|
|
DOCX_MIMETYPE = (
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
)
|
|
DOCX_SUFFIX = ".docx"
|
|
JSON_MIMETYPE = "application/json"
|
|
JSON_SUFFIX = ".json"
|
|
PDF_MIMETYPE = "application/pdf"
|
|
PDF_SUFFIX = ".pdf"
|
|
XLS_MIMETYPE = "application/vnd.ms-excel"
|
|
XLS_SUFFIX = ".xls"
|
|
XLSX_MIMETYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
XLSX_SUFFIX = ".xlsx"
|
|
XML_MIMETYPE = "text/xml"
|
|
XML_SUFFIX = ".xml"
|
|
|
|
# Format pour lesquels on exporte sans formattage des nombres (pas de perte de précision)
|
|
FORMATS_NUMERIQUES = {"csv", "xls", "xlsx", "xml", "json"}
|
|
|
|
|
|
def get_mime_suffix(format_code: str) -> tuple[str, str]:
|
|
"""Returns (MIME, SUFFIX) from format_code == "xls", "xml", ...
|
|
SUFFIX includes the dot: ".xlsx", ".xml", ...
|
|
"xls" and "xlsx" format codes give XLSX
|
|
"""
|
|
d = {
|
|
"csv": (CSV_MIMETYPE, CSV_SUFFIX),
|
|
"docx": (DOCX_MIMETYPE, DOCX_SUFFIX),
|
|
"xls": (XLSX_MIMETYPE, XLSX_SUFFIX),
|
|
"xlsx": (XLSX_MIMETYPE, XLSX_SUFFIX),
|
|
"pdf": (PDF_MIMETYPE, PDF_SUFFIX),
|
|
"xml": (XML_MIMETYPE, XML_SUFFIX),
|
|
"json": (JSON_MIMETYPE, JSON_SUFFIX),
|
|
}
|
|
return d[format_code]
|
|
|
|
|
|
# Admissions des étudiants
|
|
# Différents types de voies d'admission:
|
|
# (stocké en texte libre dans la base, mais saisie par menus pour harmoniser)
|
|
TYPE_ADMISSION_DEFAULT = "Inconnue"
|
|
TYPES_ADMISSION = (
|
|
TYPE_ADMISSION_DEFAULT,
|
|
"Parcoursup",
|
|
"Transfert",
|
|
"APB",
|
|
"APB-PC",
|
|
"CEF",
|
|
"Direct",
|
|
)
|
|
|
|
BULLETINS_VERSIONS = {
|
|
"short": "Version courte",
|
|
"selectedevals": "Version intermédiaire",
|
|
"long": "Version complète",
|
|
}
|
|
BULLETINS_VERSIONS_BUT = BULLETINS_VERSIONS | {
|
|
"butcourt": "Version courte spéciale BUT"
|
|
}
|
|
|
|
# ---- Simple python utilities
|
|
|
|
|
|
def simplesqlquote(s, maxlen=50):
|
|
"""simple SQL quoting to avoid most SQL injection attacks.
|
|
Note: we use this function in the (rare) cases where we have to
|
|
construct SQL code manually"""
|
|
s = s[:maxlen]
|
|
s.replace("'", r"\'")
|
|
s.replace(";", r"\;")
|
|
for bad in ("select", "drop", ";", "--", "insert", "delete", "xp_"):
|
|
s = s.replace(bad, "")
|
|
return s
|
|
|
|
|
|
def unescape_html(s):
|
|
"""un-escape html entities"""
|
|
s = s.strip().replace("&", "&")
|
|
s = s.replace("<", "<")
|
|
s = s.replace(">", ">")
|
|
return s
|
|
|
|
|
|
def build_url_query(url: str, **params) -> str:
|
|
"""Add parameters to existing url, as a query string"""
|
|
url_parse = urlparse(url)
|
|
query = url_parse.query
|
|
url_dict = dict(parse_qsl(query))
|
|
url_dict.update(params)
|
|
url_new_query = urlencode(url_dict)
|
|
url_parse = url_parse._replace(query=url_new_query)
|
|
new_url = urlunparse(url_parse)
|
|
return new_url
|
|
|
|
|
|
# test if obj is iterable (but not a string)
|
|
isiterable = lambda obj: getattr(obj, "__iter__", False)
|
|
|
|
|
|
def unescape_html_dict(d):
|
|
"""un-escape all dict values, recursively"""
|
|
try:
|
|
indices = list(d.keys())
|
|
except:
|
|
indices = list(range(len(d)))
|
|
for k in indices:
|
|
v = d[k]
|
|
if isinstance(v, bytes):
|
|
d[k] = unescape_html(v)
|
|
elif isiterable(v):
|
|
unescape_html_dict(v)
|
|
|
|
|
|
# Expressions used to check noms/prenoms
|
|
FORBIDDEN_CHARS_EXP = re.compile(r"[*\|~\(\)\\]")
|
|
ALPHANUM_EXP = re.compile(r"^[\w-]+$", re.UNICODE)
|
|
|
|
|
|
def is_valid_code_nip(s: str) -> bool | None:
|
|
"""True si s peut être un code NIP: au moins 6 chiffres décimaux"""
|
|
if not s:
|
|
return False
|
|
return re.match(r"^[0-9]{6,32}$", s)
|
|
|
|
|
|
def split_id(ident: str) -> list[str]:
|
|
"""ident est une chaine 'X, Y, Z'
|
|
Renvoie ['X','Y', 'Z']
|
|
"""
|
|
if ident:
|
|
ident = ident.strip()
|
|
return [x.strip() for x in ident.strip().split(",")] if ident else []
|
|
return []
|
|
|
|
|
|
def strnone(s):
|
|
"convert s to string, '' if s is false"
|
|
if s:
|
|
return str(s)
|
|
else:
|
|
return ""
|
|
|
|
|
|
def strip_str(s):
|
|
"if s is a string, strip it, if is None, do nothing"
|
|
return s.strip() if s else s
|
|
|
|
|
|
def stripquotes(s):
|
|
"strip s from spaces and quotes"
|
|
s = s.strip()
|
|
if s and ((s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'")):
|
|
s = s[1:-1]
|
|
return s
|
|
|
|
|
|
def suppress_accents(s: str) -> str:
|
|
"remove accents and suppress non ascii characters from string s"
|
|
if isinstance(s, str):
|
|
return (
|
|
unicodedata.normalize("NFD", s)
|
|
.encode("ascii", "ignore")
|
|
.decode(SCO_ENCODING)
|
|
)
|
|
return s # may be int
|
|
|
|
|
|
def normalize_edt_id(edt_id: str) -> str:
|
|
"""Normalize les identifiants edt pour faciliter la correspondance
|
|
entre les identifiants ScoDoc et ceux dans l'ics:
|
|
Passe tout en majuscules sans accents ni espaces.
|
|
"""
|
|
return (
|
|
None
|
|
if edt_id is None
|
|
else suppress_accents(edt_id or "").upper().replace(" ", "")
|
|
)
|
|
|
|
|
|
class PurgeChars:
|
|
"""delete all chars except those belonging to the specified string"""
|
|
|
|
def __init__(self, allowed_chars=""):
|
|
self.allowed_chars_set = {ord(c) for c in allowed_chars}
|
|
|
|
def __getitem__(self, x):
|
|
if x not in self.allowed_chars_set:
|
|
return None
|
|
raise LookupError()
|
|
|
|
|
|
def purge_chars(s, allowed_chars=""):
|
|
return s.translate(PurgeChars(allowed_chars=allowed_chars))
|
|
|
|
|
|
def sanitize_string(s, remove_spaces=True):
|
|
"""s is an ordinary string, encoding given by SCO_ENCODING"
|
|
suppress accents and chars interpreted in XML
|
|
Irreversible (not a quote)
|
|
|
|
For ids and some filenames
|
|
"""
|
|
# Table suppressing some chars:
|
|
to_del = "'`\"<>!&\\ " if remove_spaces else "'`\"<>!&"
|
|
trans = str.maketrans("", "", to_del)
|
|
|
|
return suppress_accents(s.translate(trans)).replace("\t", "_")
|
|
|
|
|
|
_BAD_FILENAME_CHARS = str.maketrans("", "", ":/\\&[]*?'")
|
|
|
|
|
|
def make_filename(name):
|
|
"""Try to convert name to a reasonable filename
|
|
without spaces, (back)slashes, : and without accents
|
|
"""
|
|
return (
|
|
suppress_accents(name.translate(_BAD_FILENAME_CHARS)).replace(" ", "_")
|
|
or "scodoc"
|
|
)
|
|
|
|
|
|
VALID_CARS = (
|
|
"-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.!" # no / !
|
|
)
|
|
VALID_CARS_SET = set(VALID_CARS)
|
|
VALID_EXP = re.compile("^[" + VALID_CARS + "]+$")
|
|
|
|
|
|
def sanitize_filename(filename):
|
|
"""Keep only valid chars
|
|
used for archives filenames
|
|
"""
|
|
filename = suppress_accents(filename.replace(" ", "_"))
|
|
sane = "".join([c for c in filename if c in VALID_CARS_SET])
|
|
if len(sane) < 2:
|
|
sane = time.strftime("%Y-%m-%d-%H%M%S") + "-" + sane
|
|
return sane
|
|
|
|
|
|
def is_valid_filename(filename):
|
|
"""True if filename is safe"""
|
|
return VALID_EXP.match(filename)
|
|
|
|
|
|
BOOL_STR = {
|
|
0: False,
|
|
1: True,
|
|
"": False,
|
|
"0": False,
|
|
"1": True,
|
|
"f": False,
|
|
"false": False,
|
|
"o": True,
|
|
"on": True,
|
|
"n": False,
|
|
"t": True,
|
|
"true": True,
|
|
True: True,
|
|
"v": True,
|
|
"vrai": True,
|
|
"y": True,
|
|
}
|
|
|
|
|
|
def to_bool(x) -> bool:
|
|
"""Cast value to boolean.
|
|
The value may be encoded as a string
|
|
False are: empty, "0", "False", "f", "n".
|
|
True: all other values, such as "1", "True", "foo", "bar"...
|
|
Case insentive, ignore leading and trailing spaces.
|
|
"""
|
|
if isinstance(x, str):
|
|
return BOOL_STR.get(x.lower().strip(), True)
|
|
return bool(x)
|
|
|
|
|
|
# Min/Max values for numbers stored in database:
|
|
DB_MIN_FLOAT = -1e30
|
|
DB_MAX_FLOAT = 1e30
|
|
DB_MIN_INT = -(1 << 31)
|
|
DB_MAX_INT = (1 << 31) - 1
|
|
|
|
|
|
def bul_filename_old(sem: dict, etud: dict, fmt):
|
|
"""Build a filename for this bulletin"""
|
|
dt = time.strftime("%Y-%m-%d")
|
|
filename = f"bul-{sem['titre_num']}-{dt}-{etud['nom']}.{fmt}"
|
|
filename = make_filename(filename)
|
|
return filename
|
|
|
|
|
|
def bul_filename(formsemestre, etud, prefix="bul"):
|
|
"""Build a filename for this bulletin (without suffix)"""
|
|
dt = time.strftime("%Y-%m-%d")
|
|
filename = f"{prefix}-{formsemestre.titre_num()}-{dt}-{etud.nom}"
|
|
filename = make_filename(filename)
|
|
return filename
|
|
|
|
|
|
def flash_errors(form):
|
|
"""Flashes form errors (version sommaire)"""
|
|
for field, _ in form.errors.items():
|
|
flash(
|
|
f"Erreur: voir le champ {getattr(form, field).label.text}",
|
|
"warning",
|
|
)
|
|
# see https://getbootstrap.com/docs/4.0/components/alerts/
|
|
|
|
|
|
def flash_once(message: str):
|
|
"""Flash the message, but only once per request"""
|
|
if not hasattr(g, "sco_flashed_once"):
|
|
g.sco_flashed_once = set()
|
|
if not message in g.sco_flashed_once:
|
|
flash(message)
|
|
g.sco_flashed_once.add(message)
|
|
|
|
|
|
def html_flash_message(message: str):
|
|
"""HTML for flashed messaged, for legacy codes"""
|
|
return f"""<div class="container flashes">
|
|
<div class="alert alert-info alert-message" role="alert">
|
|
{message}
|
|
</div>
|
|
</div>"""
|
|
|
|
|
|
def sendCSVFile(data, filename): # DEPRECATED utiliser send_file
|
|
"""publication fichier CSV."""
|
|
return send_file(data, filename=filename, mime=CSV_MIMETYPE, attached=True)
|
|
|
|
|
|
def sendPDFFile(data, filename): # DEPRECATED utiliser send_file
|
|
return send_file(data, filename=filename, mime=PDF_MIMETYPE, attached=True)
|
|
|
|
|
|
def sendJSON(data, attached=False, filename=None):
|
|
js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder)
|
|
return send_file(
|
|
js, filename=filename or "sco_data.json", mime=JSON_MIMETYPE, attached=attached
|
|
)
|
|
|
|
|
|
def sendXML(
|
|
data,
|
|
tagname=None,
|
|
force_outer_xml_tag=True,
|
|
attached=False,
|
|
quote=False,
|
|
filename=None,
|
|
):
|
|
if type(data) != list:
|
|
data = [data] # always list-of-dicts
|
|
if force_outer_xml_tag:
|
|
data = [{tagname: data}]
|
|
tagname += "_list"
|
|
doc = sco_xml.simple_dictlist2xml(data, tagname=tagname, quote=quote)
|
|
return send_file(
|
|
doc, filename=filename or "sco_data.xml", mime=XML_MIMETYPE, attached=attached
|
|
)
|
|
|
|
|
|
def sendResult(
|
|
data,
|
|
name=None,
|
|
fmt=None,
|
|
force_outer_xml_tag=True,
|
|
attached=False,
|
|
quote_xml=False,
|
|
filename=None,
|
|
):
|
|
if (fmt is None) or (fmt == "html"):
|
|
return data
|
|
elif fmt == "xml": # name is outer tagname
|
|
return sendXML(
|
|
data,
|
|
tagname=name,
|
|
force_outer_xml_tag=force_outer_xml_tag,
|
|
attached=attached,
|
|
quote=quote_xml,
|
|
filename=filename,
|
|
)
|
|
elif fmt == "json":
|
|
return sendJSON(data, attached=attached, filename=filename)
|
|
else:
|
|
raise ValueError(f"invalid format: {fmt}")
|
|
|
|
|
|
def send_file(data, filename="", suffix="", mime=None, attached=None):
|
|
"""Build Flask Response for file download of given type
|
|
By default (attached is None), json and xml are inlined and other types are attached.
|
|
"""
|
|
if attached is None:
|
|
if mime == XML_MIMETYPE or mime == JSON_MIMETYPE:
|
|
attached = False
|
|
else:
|
|
attached = True
|
|
if filename:
|
|
if suffix:
|
|
filename += suffix
|
|
filename = make_filename(filename)
|
|
response = make_response(data)
|
|
response.headers["Content-Type"] = mime
|
|
if attached and filename:
|
|
response.headers["Content-Disposition"] = 'attachment; filename="%s"' % filename
|
|
return response
|
|
|
|
|
|
def send_docx(document, filename):
|
|
"Send a python-docx document"
|
|
buffer = io.BytesIO() # in-memory document, no disk file
|
|
document.save(buffer)
|
|
buffer.seek(0)
|
|
return flask.send_file(
|
|
buffer,
|
|
download_name=sanitize_filename(filename),
|
|
mimetype=DOCX_MIMETYPE,
|
|
)
|
|
|
|
|
|
def get_request_args() -> dict:
|
|
"""returns a dict with request (POST or GET) arguments
|
|
converted to suit legacy Zope style (scodoc7) functions.
|
|
"""
|
|
vals = {}
|
|
# copy to get a mutable object (necessary for TrivialFormulator and several methods)
|
|
if request.method == "POST":
|
|
# request.form is a werkzeug.datastructures.ImmutableMultiDict
|
|
# must copy to get a mutable version (needed by TrivialFormulator)
|
|
vals = request.form.copy()
|
|
if request.files:
|
|
# Add files in form:
|
|
vals.update(request.files)
|
|
for k in request.form:
|
|
if k.endswith(":list"):
|
|
vals[k[:-5]] = request.form.getlist(k)
|
|
elif request.method == "GET":
|
|
for k in request.args:
|
|
# current_app.logger.debug("%s\t%s" % (k, request.args.getlist(k)))
|
|
if k.endswith(":list"):
|
|
vals[k[:-5]] = request.args.getlist(k)
|
|
else:
|
|
values = request.args.getlist(k)
|
|
vals[k] = values[0] if len(values) == 1 else values
|
|
return vals
|
|
|
|
|
|
def json_error(status_code, message=None) -> Response:
|
|
"""Simple JSON for errors."""
|
|
payload = {
|
|
"error": HTTP_STATUS_CODES.get(status_code, "Unknown error"),
|
|
"status": status_code,
|
|
}
|
|
if message:
|
|
payload["message"] = message
|
|
response = json_response(status_=status_code, data_=payload)
|
|
response.status_code = status_code
|
|
log(f"Error: {response}")
|
|
return response
|
|
|
|
|
|
def json_ok_response(status_code=200, payload=None) -> Response:
|
|
"""Simple JSON respons for "success" """
|
|
payload = payload or {"OK": True}
|
|
response = json_response(status_=status_code, data_=payload)
|
|
response.status_code = status_code
|
|
return response
|
|
|
|
|
|
def get_scodoc_version():
|
|
"return a string identifying ScoDoc version"
|
|
return sco_version.SCOVERSION
|
|
|
|
|
|
def check_scodoc7_password(scodoc7_hash, password):
|
|
"""Check a password vs scodoc7 hash
|
|
used only during old databases migrations"""
|
|
m = md5()
|
|
m.update(password.encode("utf-8"))
|
|
h = base64.encodebytes(m.digest()).decode("utf-8").strip()
|
|
return h == scodoc7_hash
|
|
|
|
|
|
# Simple string manipulations
|
|
|
|
|
|
def abbrev_prenom(prenom):
|
|
"Donne l'abreviation d'un prenom"
|
|
# un peu lent, mais espère traiter tous les cas
|
|
# Jean -> J.
|
|
# Charles -> Ch.
|
|
# Jean-Christophe -> J.-C.
|
|
# Marie Odile -> M. O.
|
|
prenom = prenom.replace(".", " ").strip()
|
|
if not prenom:
|
|
return ""
|
|
d = prenom[:3].upper()
|
|
if d == "CHA":
|
|
abrv = "Ch." # 'Charles' donne 'Ch.'
|
|
i = 3
|
|
else:
|
|
abrv = prenom[0].upper() + "."
|
|
i = 1
|
|
n = len(prenom)
|
|
while i < n:
|
|
c = prenom[i]
|
|
if c == " " or c == "-" and i < n - 1:
|
|
sep = c
|
|
i += 1
|
|
# gobbe tous les separateurs
|
|
while i < n and (prenom[i] == " " or prenom[i] == "-"):
|
|
if prenom[i] == "-":
|
|
sep = "-"
|
|
i += 1
|
|
if i < n:
|
|
abrv += sep + prenom[i].upper() + "."
|
|
i += 1
|
|
return abrv
|
|
|
|
|
|
def format_civilite(civilite):
|
|
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre,
|
|
personne ne souhaitant pas d'affichage).
|
|
Raises ScoValueError if conversion fails.
|
|
"""
|
|
try:
|
|
return {
|
|
"M": "M.",
|
|
"F": "Mme",
|
|
"X": "",
|
|
}[civilite]
|
|
except KeyError as exc:
|
|
raise ScoValueError(f"valeur invalide pour la civilité: {civilite}") from exc
|
|
|
|
|
|
def format_nomprenom(etud, reverse=False):
|
|
"""Formatte civilité/nom/prenom pour affichages: "M. Pierre Dupont"
|
|
Si reverse, "Dupont Pierre", sans civilité.
|
|
|
|
DEPRECATED: utiliser Identite.nomprenom
|
|
"""
|
|
nom = etud.get("nom_disp", "") or etud.get("nom_usuel", "") or etud["nom"]
|
|
prenom = format_prenom(etud["prenom"])
|
|
civilite = format_civilite(etud["civilite"])
|
|
if reverse:
|
|
fs = [nom, prenom]
|
|
else:
|
|
fs = [civilite, prenom, nom]
|
|
return " ".join([x for x in fs if x])
|
|
|
|
|
|
def format_nom(s, uppercase=True):
|
|
"Formatte le nom"
|
|
if not s:
|
|
return ""
|
|
if uppercase:
|
|
return s.upper()
|
|
else:
|
|
return format_prenom(s)
|
|
|
|
|
|
def format_prenom(s):
|
|
"""Formatte prenom etudiant pour affichage
|
|
DEPRECATED: utiliser Identite.prenom_str
|
|
"""
|
|
if not s:
|
|
return ""
|
|
frags = s.split()
|
|
r = []
|
|
for frag in frags:
|
|
fs = frag.split("-")
|
|
r.append("-".join([x.lower().capitalize() for x in fs]))
|
|
return " ".join(r)
|
|
|
|
|
|
def format_telephone(n: str | None) -> str:
|
|
"Format a phone number for display"
|
|
if n is None:
|
|
return ""
|
|
if len(n) < 7:
|
|
return n
|
|
n = n.replace(" ", "").replace(".", "")
|
|
i = 0
|
|
r = ""
|
|
j = len(n) - 1
|
|
while j >= 0:
|
|
r = n[j] + r
|
|
if i % 2 == 1 and j != 0:
|
|
r = " " + r
|
|
i += 1
|
|
j -= 1
|
|
if len(r) == 13 and r[0] != "0":
|
|
r = "0" + r
|
|
return r
|
|
|
|
|
|
#
|
|
def timedate_human_repr():
|
|
"representation du temps courant pour utilisateur"
|
|
return time.strftime(DATEATIME_FMT)
|
|
|
|
|
|
def annee_scolaire_repr(year, month):
|
|
"""representation de l'annee scolaire : '2009 - 2010'
|
|
à partir d'une date.
|
|
"""
|
|
if month >= MONTH_DEBUT_ANNEE_SCOLAIRE: # apres le 1er aout
|
|
return f"{year} - {year + 1}"
|
|
else:
|
|
return f"{year - 1} - {year}"
|
|
|
|
|
|
def annee_scolaire() -> int:
|
|
"""Année de debut de l'annee scolaire courante"""
|
|
t = time.localtime()
|
|
year, month = t[0], t[1]
|
|
return annee_scolaire_debut(year, month)
|
|
|
|
|
|
def annee_scolaire_debut(year, month) -> int:
|
|
"""Annee scolaire de début.
|
|
Par défaut (hémisphère nord), l'année du mois de août
|
|
précédent la date indiquée.
|
|
"""
|
|
if int(month) >= MONTH_DEBUT_ANNEE_SCOLAIRE:
|
|
return int(year)
|
|
else:
|
|
return int(year) - 1
|
|
|
|
|
|
def date_debut_annee_scolaire(annee_sco: int | None = None) -> datetime.datetime:
|
|
"""La date de début de l'année scolaire
|
|
Si annee_sco n'est pas spécifié, année courante
|
|
(par défaut, l'année scolaire en métropole commence le 1er aout)
|
|
"""
|
|
if annee_sco is None:
|
|
annee_sco = annee_scolaire()
|
|
return datetime.datetime(year=annee_sco, month=MONTH_DEBUT_ANNEE_SCOLAIRE, day=1)
|
|
|
|
|
|
def date_fin_annee_scolaire(annee_sco: int | None = None) -> datetime.datetime:
|
|
"""La date de fin de l'année scolaire
|
|
(par défaut, le 31 juillet de l'année suivante)
|
|
"""
|
|
# on prend la date de début de l'année scolaire suivante,
|
|
# et on lui retire 1 jour.
|
|
# On s'affranchit ainsi des problèmes de durées de mois.
|
|
if annee_sco is None:
|
|
annee_sco = annee_scolaire()
|
|
return datetime.datetime(
|
|
year=annee_sco + 1, month=MONTH_DEBUT_ANNEE_SCOLAIRE, day=1
|
|
) - datetime.timedelta(days=1)
|
|
|
|
|
|
def sem_decale_str(sem):
|
|
"""'D' si semestre decalé, ou ''"""
|
|
# considère "décalé" les semestre impairs commençant entre janvier et juin
|
|
# et les pairs entre juillet et decembre
|
|
if sem["semestre_id"] <= 0:
|
|
return ""
|
|
if (sem["semestre_id"] % 2 and sem["mois_debut_ord"] <= 6) or (
|
|
not sem["semestre_id"] % 2 and sem["mois_debut_ord"] > 6
|
|
):
|
|
return "D"
|
|
else:
|
|
return ""
|
|
|
|
|
|
def is_valid_mail(email):
|
|
"""True if well-formed email address"""
|
|
return re.match(r"^.+@.+\..{2,3}$", email)
|
|
|
|
|
|
def graph_from_edges(edges, graph_name="mygraph"):
|
|
"""Crée un graph pydot
|
|
à partir d'une liste d'arêtes [ (n1, n2), (n2, n3), ... ]
|
|
où n1, n2, ... sont des chaînes donnant l'id des nœuds.
|
|
|
|
Fonction remplaçant celle de pydot qui est buggée.
|
|
"""
|
|
nodes = set([it for tup in edges for it in tup])
|
|
graph = pydot.Dot(graph_name)
|
|
for n in nodes:
|
|
graph.add_node(pydot.Node(n))
|
|
for e in edges:
|
|
graph.add_edge(pydot.Edge(src=e[0], dst=e[1]))
|
|
return graph
|
|
|
|
|
|
ICONSIZES = {} # name : (width, height) cache image sizes
|
|
|
|
|
|
def icontag(name, file_format="png", no_size=False, **attrs):
|
|
"""tag HTML pour un icone.
|
|
(dans les versions anterieures on utilisait Zope)
|
|
Les icones sont des fichiers PNG dans .../static/icons
|
|
Si la taille (width et height) n'est pas spécifiée, lit l'image
|
|
pour la mesurer (et cache le résultat).
|
|
"""
|
|
if (not no_size) and (("width" not in attrs) or ("height" not in attrs)):
|
|
if name not in ICONSIZES:
|
|
img_file = os.path.join(
|
|
Config.SCODOC_DIR,
|
|
"app/static/icons/%s.%s"
|
|
% (
|
|
name,
|
|
file_format,
|
|
),
|
|
)
|
|
with PILImage.open(img_file) as image:
|
|
width, height = image.size[0], image.size[1]
|
|
ICONSIZES[name] = (width, height) # cache
|
|
else:
|
|
width, height = ICONSIZES[name]
|
|
attrs["width"] = width
|
|
attrs["height"] = height
|
|
if "border" not in attrs:
|
|
attrs["border"] = 0
|
|
if "alt" not in attrs:
|
|
attrs["alt"] = "logo %s" % name
|
|
s = " ".join(['%s="%s"' % (k, attrs[k]) for k in attrs])
|
|
return f'<img class="{name}" {s} src="{STATIC_DIR}/icons/{name}.{file_format}" />'
|
|
|
|
|
|
ICON_PDF = icontag("pdficon16x20_img", title="Version PDF")
|
|
ICON_XLS = icontag("xlsicon_img", title="Export tableur (xlsx)")
|
|
|
|
ICON_PUBLISHED = """<img src="/ScoDoc/static/icons/eye_visible_green.svg"
|
|
width="24" height="19" border="0"
|
|
title="Bulletins publiés sur la passerelle étudiants"
|
|
alt="Bulletins publiés sur la passerelle étudiants" />"""
|
|
ICON_HIDDEN = """<img src="/ScoDoc/static/icons/eye_hidden.svg"
|
|
width="24" height="19" border="0"
|
|
title="Bulletins NON publiés sur la passerelle étudiants"
|
|
alt="Bulletins NON publiés sur la passerelle étudiants" />"""
|
|
|
|
# HTML emojis
|
|
EMO_WARNING = "⚠️" # warning /!\
|
|
EMO_RED_TRIANGLE_DOWN = "🔻" # red triangle pointed down
|
|
EMO_PREV_ARROW = "❮"
|
|
EMO_NEXT_ARROW = "❯"
|
|
|
|
|
|
def heterogeneous_sorting_key(x):
|
|
"key to sort non homogeneous sequences"
|
|
return (float(x), "") if isinstance(x, (bool, float, int)) else (-1e34, str(x))
|
|
|
|
|
|
def query_portal(req, msg="Portail Apogee", timeout=3):
|
|
"""Retreives external data using HTTP request
|
|
(used to connect to Apogee portal, or ScoDoc server)
|
|
returns a string, "" on error
|
|
"""
|
|
log("query_portal: %s" % req)
|
|
error_message = None
|
|
try:
|
|
r = requests.get(req, timeout=timeout) # seconds / request
|
|
except requests.ConnectionError:
|
|
error_message = "ConnectionError"
|
|
except requests.Timeout:
|
|
error_message = "Timeout"
|
|
except requests.TooManyRedirects:
|
|
error_message = "TooManyRedirects"
|
|
except requests.RequestException:
|
|
error_message = f"can't connect to {msg}"
|
|
if error_message is not None:
|
|
log(f"query_portal: {error_message}")
|
|
return ""
|
|
if r.status_code != 200:
|
|
log(f"query_portal: http error {r.status_code}")
|
|
return ""
|
|
|
|
return r.text
|
|
|
|
|
|
def confirm_dialog(
|
|
message="<p>Confirmer ?</p>",
|
|
OK="OK",
|
|
add_headers=True, # complete page
|
|
cancel_label="Annuler",
|
|
cancel_url="",
|
|
dest_url="",
|
|
help_msg=None,
|
|
parameters: dict = None,
|
|
target_variable="dialog_confirmed",
|
|
template="sco_page.j2",
|
|
):
|
|
"""HTML confirmation dialog: submit (POST) to same page or dest_url if given."""
|
|
parameters = parameters or {}
|
|
# dialog de confirmation simple
|
|
parameters[target_variable] = 1
|
|
# Attention: la page a pu etre servie en GET avec des parametres
|
|
# si on laisse l'url "action" vide, les parametres restent alors que l'on passe en POST...
|
|
if not dest_url:
|
|
action = ""
|
|
else:
|
|
# strip remaining parameters from destination url:
|
|
dest_url = urllib.parse.splitquery(dest_url)[0]
|
|
action = f'action="{dest_url}"'
|
|
|
|
H = [
|
|
f"""<form {action} method="POST">
|
|
{message}
|
|
|
|
<div class="form-group space-before-24">
|
|
""",
|
|
]
|
|
if OK or not cancel_url:
|
|
H.append(f'<input class="btn btn-default" type="submit" value="{OK}"/>')
|
|
if cancel_url:
|
|
H.append(
|
|
f"""<input class="btn btn-default" type="submit" name="cancel" type ="button" value="{cancel_label}"
|
|
onClick="event.preventDefault(); document.location='{cancel_url}';"/>"""
|
|
)
|
|
H.append("</div>")
|
|
for param in parameters.keys():
|
|
if parameters[param] is None:
|
|
parameters[param] = ""
|
|
if isinstance(parameters[param], list):
|
|
for e in parameters[param]:
|
|
H.append(f"""<input type="hidden" name="{param}" value="{e}"/>""")
|
|
else:
|
|
H.append(
|
|
f"""<input type="hidden" name="{param}" value="{parameters[param]}"/>"""
|
|
)
|
|
H.append("</form>")
|
|
if help_msg:
|
|
H.append('<p class="help">' + help_msg + "</p>")
|
|
if add_headers:
|
|
return render_template(template, content="\n".join(H))
|
|
else:
|
|
return "\n".join(H)
|
|
|
|
|
|
def objects_renumber(db, obj_list) -> None:
|
|
"""fixe les numeros des objets d'une liste de modèles
|
|
pour ne pas changer son ordre"""
|
|
log("objects_renumber")
|
|
for i, obj in enumerate(obj_list):
|
|
obj.numero = i
|
|
db.session.add(obj)
|
|
db.session.commit()
|
|
|
|
|
|
def comp_ranks(tab: list[tuple]) -> dict[int, str]:
|
|
"""Calcul rangs à partir d'une liste ordonnée de tuples [ (valeur, ..., etudid) ]
|
|
(valeur est une note numérique), en tenant compte des ex-aequos
|
|
Le resultat est: { etudid : rang } où rang est une chaine decrivant le rang
|
|
"""
|
|
rangs = {} # { etudid : rang } (rang est une chaine)
|
|
nb_ex = 0 # nb d'ex-aequo consécutifs en cours
|
|
for i, row in enumerate(tab):
|
|
# test ex-aequo
|
|
if i < len(tab) - 1:
|
|
next_val = tab[i + 1][0]
|
|
else:
|
|
next_val = None
|
|
moy = row[0]
|
|
if nb_ex:
|
|
srang = "%d ex" % (i + 1 - nb_ex)
|
|
if moy == next_val:
|
|
nb_ex += 1
|
|
else:
|
|
nb_ex = 0
|
|
else:
|
|
if moy == next_val:
|
|
srang = "%d ex" % (i + 1 - nb_ex)
|
|
nb_ex = 1
|
|
else:
|
|
srang = "%d" % (i + 1)
|
|
rangs[row[-1]] = srang
|
|
return rangs
|
|
|
|
|
|
def gen_cell(key: str, row: dict, elt="td", with_col_class=False):
|
|
"html table cell"
|
|
klass = row.get(f"_{key}_class", "")
|
|
if with_col_class:
|
|
klass = key + " " + klass
|
|
attrs = f'class="{klass}"' if klass else ""
|
|
if elt == "th":
|
|
attrs += ' scope="row"'
|
|
data = row.get(f"_{key}_data") # dict
|
|
if data:
|
|
for k in data:
|
|
attrs += f' data-{k}="{data[k]}"'
|
|
order = row.get(f"_{key}_order")
|
|
if order:
|
|
attrs += f' data-order="{order}"'
|
|
content = row.get(key, "")
|
|
target = row.get(f"_{key}_target")
|
|
target_attrs = row.get(f"_{key}_target_attrs", "")
|
|
if target or target_attrs: # avec lien
|
|
href = f'href="{target}"' if target else ""
|
|
content = f"<a {href} {target_attrs}>{content}</a>"
|
|
return f"<{elt} {attrs}>{content}</{elt}>"
|
|
|
|
|
|
def gen_row(
|
|
keys: list[str], row, elt="td", selected_etudid=None, with_col_classes=False
|
|
):
|
|
"html table row"
|
|
klass = row.get("_tr_class")
|
|
if row.get("etudid", "") == selected_etudid:
|
|
klass += " row_selected"
|
|
tr_class = f'class="{klass}"' if klass else ""
|
|
return f"""<tr {tr_class}>{
|
|
"".join([gen_cell(key, row, elt, with_col_class=with_col_classes)
|
|
for key in keys if not key.startswith('_')])
|
|
}</tr>"""
|
|
|
|
|
|
# Pour accès depuis les templates jinja
|
|
def is_entreprises_enabled():
|
|
from app.models import ScoDocSiteConfig
|
|
|
|
return ScoDocSiteConfig.is_entreprises_enabled()
|
|
|
|
|
|
def is_passerelle_disabled():
|
|
from app.models import ScoDocSiteConfig
|
|
|
|
return ScoDocSiteConfig.is_passerelle_disabled()
|
|
|
|
|
|
def is_assiduites_module_forced(
|
|
formsemestre_id: int = None, dept_id: int = None
|
|
) -> bool:
|
|
"""Vrai si préférence "imposer la saisie du module" sur les assiduités est vraie."""
|
|
from app.scodoc import sco_preferences
|
|
|
|
return sco_preferences.get_preference(
|
|
"forcer_module", formsemestre_id=formsemestre_id, dept_id=dept_id
|
|
)
|
|
|
|
|
|
def get_assiduites_time_config(config_type: str) -> str | int:
|
|
"Renvoie config demandée"
|
|
# config_type devrait être le nom de la variable de config pour rester cohérent...
|
|
from app.models import ScoDocSiteConfig
|
|
|
|
match config_type:
|
|
case "matin" | "assi_morning_time":
|
|
return ScoDocSiteConfig.get("assi_morning_time", "08:00:00")
|
|
case "aprem" | "assi_afternoon_time":
|
|
return ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00")
|
|
case "pivot" | "assi_lunch_time":
|
|
return ScoDocSiteConfig.get("assi_lunch_time", "13:00:00")
|
|
case "tick" | "assi_tick_time":
|
|
return ScoDocSiteConfig.get("assi_tick_time", 15)
|
|
raise ValueError(f"invalid config_type: {config_type}")
|