ScoDoc/app/scodoc/sco_utils.py

1721 lines
51 KiB
Python
Raw Normal View History

2020-09-26 16:19:37 +02:00
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
2023-12-31 23:04:06 +01:00
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
2020-09-26 16:19:37 +02:00
#
# 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
#
##############################################################################
2024-07-31 13:43:39 +02:00
"""Common definitions"""
2021-07-05 00:07:17 +02:00
import base64
2021-02-03 22:00:41 +01:00
import bisect
import collections
import datetime
2023-04-17 15:34:00 +02:00
from enum import IntEnum, Enum
2022-04-08 13:01:47 +02:00
import io
import json
2021-07-05 00:07:17 +02:00
from hashlib import md5
2020-09-26 16:19:37 +02:00
import numbers
import os
import re
2023-04-17 15:34:00 +02:00
from shutil import get_terminal_size
import _thread
import time
2021-07-12 11:54:04 +02:00
import unicodedata
import urllib
from urllib.parse import urlparse, parse_qsl, urlunparse, urlencode
2021-07-10 13:55:35 +02:00
import numpy as np
2020-09-26 16:19:37 +02:00
from PIL import Image as PILImage
import pydot
import requests
2020-09-26 16:19:37 +02:00
2023-04-17 15:34:00 +02:00
from pytz import timezone
2022-04-08 13:01:47 +02:00
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
2021-06-15 12:34:33 +02:00
2021-05-29 18:22:51 +02:00
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
2021-07-10 13:55:35 +02:00
from app.scodoc import sco_xml
2021-08-21 17:07:44 +02:00
import sco_version
2023-08-22 17:02:00 +02:00
# 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 !
2023-08-25 17:58:57 +02:00
TIME_ZONE = timezone("/".join(os.path.realpath("/etc/localtime").split("/")[-2:]))
"La timezone du serveur"
2020-09-26 16:19:37 +02:00
# ----- 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
2020-09-26 16:19:37 +02:00
# ----- 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
2020-09-26 16:19:37 +02:00
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)
2023-01-29 21:52:39 +01:00
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")
2023-04-17 15:34:00 +02:00
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)
2024-02-27 15:59:48 +01:00
autosize - Optional : Choisir automatiquement la taille de la barre
en fonction du terminal (Bool)
2023-04-17 15:34:00 +02:00
"""
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
2024-03-29 15:36:35 +01:00
=> (respect de la convention des constantes)
2023-04-17 15:34:00 +02:00
"""
@classmethod
def contains(cls, attr: str):
"""Vérifie sur un attribut existe dans l'enum"""
2024-02-27 15:59:48 +01:00
# Existe dans la classe parent de Enum (EnumType)
# pylint: disable-next=no-member
2023-04-17 15:34:00 +02:00
return attr.upper() in cls._member_names_
2023-08-22 15:43:10 +02:00
@classmethod
2024-03-29 15:36:35 +01:00
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
)
2023-08-22 15:43:10 +02:00
2023-04-17 15:34:00 +02:00
@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:
2024-02-27 15:59:48 +01:00
"""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, "")
2023-04-17 15:34:00 +02:00
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:
2024-02-27 15:59:48 +01:00
"""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_
2023-04-17 15:34:00 +02:00
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"]:
"""
2024-02-27 15:59:48 +01:00
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:
2024-02-27 15:59:48 +01:00
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>
"""
2024-02-27 15:59:48 +01:00
# 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(",")
]
2024-01-20 20:19:50 +01:00
def is_iso_formated(date: str, convert=False) -> bool | datetime.datetime | None:
2023-04-17 15:34:00 +02:00
"""
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)
2023-04-17 15:34:00 +02:00
return date if convert else True
except (ValueError, TypeError):
2023-04-17 15:34:00 +02:00
return None if convert else False
2024-01-19 17:06:01 +01:00
def localize_datetime(date: datetime.datetime) -> datetime.datetime:
2024-01-18 17:05:43 +01:00
"""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
"""
2023-04-17 15:34:00 +02:00
new_date: datetime.datetime = date
if new_date.tzinfo is None:
try:
2023-09-05 14:25:38 +02:00
new_date = TIME_ZONE.localize(date)
2023-04-17 15:34:00 +02:00
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
2023-04-17 15:34:00 +02:00
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
2023-04-17 15:34:00 +02:00
"""
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
2023-08-22 15:43:10 +02:00
class AssiduitesMetrics:
2023-12-12 15:32:47 +01:00
"""Labels associés à la métrique de l'assiduité"""
2023-08-22 15:43:10 +02:00
2023-12-12 15:32:47 +01:00
SHORT: list[str] = ["1/2 J.", "J.", "H."] # forme stockée en pref.
LONG: list[str] = ["Demi-journée", "Journée", "Heure"]
2023-08-22 15:43:10 +02:00
TAG: list[str] = ["demi", "journee", "heure"]
2023-12-12 15:32:47 +01:00
@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()
2023-08-22 15:43:10 +02:00
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
2020-09-26 16:19:37 +02:00
# 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
2022-03-26 23:33:57 +01:00
@classmethod
def get_abbrev(cls, code) -> str:
"""Abbréviation décrivant le type de module à partir du code integer:
2022-03-26 23:33:57 +01:00
"mod", "malus", "res", "sae"
(utilisées pour style CSS)
2022-03-26 23:33:57 +01:00
"""
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É",
2022-01-04 15:03:38 +01:00
None: "Module",
}
PARTITION_PARCOURS = "Parcours"
2020-09-26 16:19:37 +02:00
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
2020-09-26 16:19:37 +02:00
)
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",
)
2022-11-09 12:50:10 +01:00
# 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
2022-11-09 12:50:10 +01:00
2021-12-04 21:04:09 +01:00
MONTH_NAMES_ABBREV = (
"Jan ",
"Fév ",
"Mars",
"Avr ",
"Mai ",
"Juin",
"Juil ",
2021-12-04 21:04:09 +01:00
"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")
2021-12-04 21:04:09 +01:00
2024-08-24 08:06:46 +02:00
TIME_FMT = "%Hh%M" # affichage des heures
DATE_FMT = "%d/%m/%Y" # affichage des dates
2024-04-03 18:47:44 +02:00
DATEATIME_FMT = DATE_FMT + " à " + TIME_FMT
DATETIME_FMT = DATE_FMT + " " + TIME_FMT
2020-09-26 16:19:37 +02:00
def fmt_note(
val, note_max=None, keep_numeric=False, fixed_precision_str=True
) -> str | float:
2020-09-26 16:19:37 +02:00
"""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)
2020-09-26 16:19:37 +02:00
"""
2021-12-11 10:56:40 +01:00
if val is None or val == NOTES_ABSENCE:
2020-09-26 16:19:37 +02:00
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
2022-05-10 18:21:28 +02:00
if not isinstance(val, str):
if np.isnan(val):
2021-12-06 10:57:10 +01:00
return "~"
if (note_max is not None) and note_max > 0:
2020-09-26 16:19:37 +02:00
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
2020-09-26 16:19:37 +02:00
s = "0" * (5 - len(s)) + s # padding: 0 à gauche pour longueur 5: "12.34"
return s
return f"{val:g}"
return val.replace("NA", "-")
2020-09-26 16:19:37 +02:00
def fmt_coef(val):
"""Conversion valeur coefficient (float) en chaine"""
2020-09-26 16:19:37 +02:00
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
2020-09-26 16:19:37 +02:00
=> 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)
2021-12-05 20:21:51 +01:00
def jsnan(x):
"if x is NaN, returns None"
if isinstance(x, numbers.Number) and np.isnan(x):
return None
return x
2020-09-26 16:19:37 +02:00
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 ""
2020-09-26 16:19:37 +02:00
def group_by_key(d: dict, key) -> dict:
grouped = collections.defaultdict(lambda: [])
for e in d:
grouped[e[key]].append(e)
return grouped
2020-09-26 16:19:37 +02:00
# ----- Global lock for critical sections (except notes_tables caches)
GSL = _thread.allocate_lock() # Global ScoDoc Lock
2020-09-26 16:19:37 +02:00
SCODOC_DIR = Config.SCODOC_DIR
2021-05-29 18:22:51 +02:00
# ----- Repertoire "config" modifiable
# /opt/scodoc-data/config
SCODOC_CFG_DIR = os.path.join(Config.SCODOC_VAR_DIR, "config")
2021-05-29 18:22:51 +02:00
# ----- 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")
2021-09-25 09:13:39 +02:00
if not os.path.exists(SCO_TMP_DIR) and os.path.exists(Config.SCODOC_VAR_DIR):
2021-05-29 18:22:51 +02:00
os.mkdir(SCO_TMP_DIR, 0o755)
# ----- Les logos: /opt/scodoc-data/config/logos
2021-05-29 18:22:51 +02:00
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)
2021-05-29 18:22:51 +02:00
# ----- Les outils distribués
SCO_TOOLS_DIR = os.path.join(Config.SCODOC_DIR, "tools")
2020-09-26 16:19:37 +02:00
# ----- Lecture du fichier de configuration
from app.scodoc import sco_config
from app.scodoc import sco_config_load
2020-09-26 16:19:37 +02:00
2021-05-29 18:22:51 +02:00
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
2020-09-26 16:19:37 +02:00
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"
2020-09-26 16:19:37 +02:00
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"
2023-06-28 21:25:38 +02:00
SCO_ANNONCES_WEBSITE = "https://scodoc.org/Contact"
2020-09-26 16:19:37 +02:00
SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr"
SCO_USERS_LIST = "notes@listes.univ-paris13.fr"
SCO_LISTS_URL = "https://scodoc.org/Contact"
2022-09-10 15:23:54 +02:00
SCO_DISCORD_ASSISTANCE = "https://discord.gg/ybw6ugtFsZ"
2020-09-26 16:19:37 +02:00
# 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)
2021-10-14 11:01:29 +02:00
SCO_DUMP_UP_URL = "https://scodoc.org/scodoc-installmgr/upload-dump"
2022-03-20 23:12:30 +01:00
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
2020-09-26 16:19:37 +02:00
CSV_FIELDSEP = ";"
CSV_LINESEP = "\n"
CSV_MIMETYPE = "text/comma-separated-values"
CSV_SUFFIX = ".csv"
2022-04-08 13:01:47 +02:00
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"
2020-09-26 16:19:37 +02:00
XLS_MIMETYPE = "application/vnd.ms-excel"
XLS_SUFFIX = ".xls"
2021-08-02 08:52:07 +02:00
XLSX_MIMETYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
2021-08-12 14:49:53 +02:00
XLSX_SUFFIX = ".xlsx"
2020-09-26 16:19:37 +02:00
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),
2022-04-08 13:01:47 +02:00
"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]
2020-09-26 16:19:37 +02:00
# 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",
)
2020-09-26 16:19:37 +02:00
BULLETINS_VERSIONS = {
"short": "Version courte",
"selectedevals": "Version intermédiaire",
"long": "Version complète",
}
BULLETINS_VERSIONS_BUT = BULLETINS_VERSIONS | {
"butcourt": "Version courte spéciale BUT"
}
2021-06-15 12:34:33 +02:00
# ---- Simple python utilities
2020-09-26 16:19:37 +02:00
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("&amp;", "&")
s = s.replace("&lt;", "<")
s = s.replace("&gt;", ">")
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
2020-09-26 16:19:37 +02:00
# 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:
2021-07-09 17:47:06 +02:00
indices = list(d.keys())
2020-09-26 16:19:37 +02:00
except:
2021-07-09 17:47:06 +02:00
indices = list(range(len(d)))
2020-09-26 16:19:37 +02:00
for k in indices:
v = d[k]
2021-07-11 22:32:01 +02:00
if isinstance(v, bytes):
2020-09-26 16:19:37 +02:00
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)
2024-05-20 15:51:33 +02:00
def is_valid_code_nip(s: str) -> bool | None:
"""True si s peut être un code NIP: au moins 6 chiffres décimaux"""
2020-09-26 16:19:37 +02:00
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 []
2020-09-26 16:19:37 +02:00
def strnone(s):
"convert s to string, '' if s is false"
if s:
return str(s)
else:
return ""
2023-02-20 21:04:29 +01:00
def strip_str(s):
"if s is a string, strip it, if is None, do nothing"
return s.strip() if s else s
2020-09-26 16:19:37 +02:00
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
2024-02-08 22:09:11 +01:00
def suppress_accents(s: str) -> str:
2021-07-12 11:54:04 +02:00
"remove accents and suppress non ascii characters from string s"
2021-08-11 00:36:07 +02:00
if isinstance(s, str):
return (
unicodedata.normalize("NFD", s)
.encode("ascii", "ignore")
.decode(SCO_ENCODING)
)
return s # may be int
2020-09-26 16:19:37 +02:00
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(" ", "")
)
2021-09-25 22:42:44 +02:00
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):
2020-09-26 16:19:37 +02:00
"""s is an ordinary string, encoding given by SCO_ENCODING"
suppress accents and chars interpreted in XML
2020-09-26 16:19:37 +02:00
Irreversible (not a quote)
For ids and some filenames
"""
2021-07-12 10:51:45 +02:00
# Table suppressing some chars:
to_del = "'`\"<>!&\\ " if remove_spaces else "'`\"<>!&"
trans = str.maketrans("", "", to_del)
return suppress_accents(s.translate(trans)).replace("\t", "_")
2021-07-12 10:51:45 +02:00
_BAD_FILENAME_CHARS = str.maketrans("", "", ":/\\&[]*?'")
2020-09-26 16:19:37 +02:00
def make_filename(name):
2021-07-12 10:51:45 +02:00
"""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"
)
2020-09-26 16:19:37 +02:00
VALID_CARS = (
"-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.!" # no / !
)
VALID_CARS_SET = set(VALID_CARS)
2020-09-26 16:19:37 +02:00
VALID_EXP = re.compile("^[" + VALID_CARS + "]+$")
def sanitize_filename(filename):
"""Keep only valid chars
used for archives filenames
"""
2021-08-31 20:18:50 +02:00
filename = suppress_accents(filename.replace(" ", "_"))
2020-09-26 16:19:37 +02:00
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)
2022-08-06 22:31:41 +02:00
BOOL_STR = {
0: False,
1: True,
2022-08-06 22:31:41 +02:00
"": False,
"0": False,
"1": True,
"f": False,
"false": False,
"o": True,
"on": True,
"n": False,
"t": True,
2022-08-06 22:31:41 +02:00
"true": True,
True: True,
"v": True,
"vrai": True,
"y": True,
2022-08-06 22:31:41 +02:00
}
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.
"""
2022-08-06 22:31:41 +02:00
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
2023-08-30 15:59:11 +02:00
def bul_filename(formsemestre, etud, prefix="bul"):
2023-07-12 14:06:34 +02:00
"""Build a filename for this bulletin (without suffix)"""
dt = time.strftime("%Y-%m-%d")
2023-08-30 15:59:11 +02:00
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)
2024-07-28 17:40:40 +02:00
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)
2020-09-26 16:19:37 +02:00
def sendPDFFile(data, filename): # DEPRECATED utiliser send_file
return send_file(data, filename=filename, mime=PDF_MIMETYPE, attached=True)
2020-09-26 16:19:37 +02:00
2022-03-01 09:48:37 +01:00
def sendJSON(data, attached=False, filename=None):
2021-07-12 22:38:30 +02:00
js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder)
2021-09-25 22:42:44 +02:00
return send_file(
2022-03-01 09:48:37 +01:00
js, filename=filename or "sco_data.json", mime=JSON_MIMETYPE, attached=attached
2021-09-25 22:42:44 +02:00
)
2020-09-26 16:19:37 +02:00
2022-03-01 09:48:37 +01:00
def sendXML(
data,
tagname=None,
force_outer_xml_tag=True,
attached=False,
2022-08-05 07:03:35 +02:00
quote=False,
2022-03-01 09:48:37 +01:00
filename=None,
):
2021-07-09 17:47:06 +02:00
if type(data) != list:
2020-09-26 16:19:37 +02:00
data = [data] # always list-of-dicts
if force_outer_xml_tag:
2021-09-13 17:10:38 +02:00
data = [{tagname: data}]
tagname += "_list"
2021-10-20 22:34:06 +02:00
doc = sco_xml.simple_dictlist2xml(data, tagname=tagname, quote=quote)
2022-03-01 09:48:37 +01:00
return send_file(
doc, filename=filename or "sco_data.xml", mime=XML_MIMETYPE, attached=attached
)
2020-09-26 16:19:37 +02:00
2021-10-20 22:34:06 +02:00
def sendResult(
data,
name=None,
fmt=None,
2021-10-20 22:34:06 +02:00
force_outer_xml_tag=True,
attached=False,
2022-08-05 07:03:35 +02:00
quote_xml=False,
2022-03-01 09:48:37 +01:00
filename=None,
2021-10-20 22:34:06 +02:00
):
if (fmt is None) or (fmt == "html"):
2020-09-26 16:19:37 +02:00
return data
elif fmt == "xml": # name is outer tagname
2021-09-25 22:42:44 +02:00
return sendXML(
data,
tagname=name,
force_outer_xml_tag=force_outer_xml_tag,
attached=attached,
2021-10-20 22:34:06 +02:00
quote=quote_xml,
2022-03-01 09:48:37 +01:00
filename=filename,
2021-09-25 22:42:44 +02:00
)
elif fmt == "json":
2022-03-01 09:48:37 +01:00
return sendJSON(data, attached=attached, filename=filename)
2020-09-26 16:19:37 +02:00
else:
raise ValueError(f"invalid format: {fmt}")
2020-09-26 16:19:37 +02:00
2021-09-24 12:10:53 +02:00
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
2021-09-24 12:10:53 +02:00
if filename:
if suffix:
filename += suffix
filename = make_filename(filename)
response = make_response(data)
response.headers["Content-Type"] = mime
2021-09-24 12:10:53 +02:00
if attached and filename:
response.headers["Content-Disposition"] = 'attachment; filename="%s"' % filename
return response
2022-04-08 13:01:47 +02:00
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),
2022-04-08 13:01:47 +02:00
mimetype=DOCX_MIMETYPE,
)
2024-06-04 23:15:50 +02:00
def get_request_args() -> dict:
"""returns a dict with request (POST or GET) arguments
converted to suit legacy Zope style (scodoc7) functions.
"""
2024-06-04 23:15:50 +02:00
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
2020-12-21 18:42:02 +01:00
def get_scodoc_version():
"return a string identifying ScoDoc version"
2021-08-26 23:43:54 +02:00
return sco_version.SCOVERSION
2020-09-26 16:19:37 +02:00
2021-07-05 00:07:17 +02:00
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"))
2021-08-20 10:53:24 +02:00
h = base64.encodebytes(m.digest()).decode("utf-8").strip()
2021-07-05 00:07:17 +02:00
return h == scodoc7_hash
2020-09-26 16:19:37 +02:00
# 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.
2021-07-12 15:13:10 +02:00
prenom = prenom.replace(".", " ").strip()
2020-09-26 16:19:37 +02:00
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
2021-07-12 15:13:10 +02:00
return abrv
2020-09-26 16:19:37 +02:00
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
2020-09-26 16:19:37 +02:00
#
def timedate_human_repr():
"representation du temps courant pour utilisateur"
2024-08-24 08:06:46 +02:00
return time.strftime(DATEATIME_FMT)
2020-09-26 16:19:37 +02:00
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}"
2020-09-26 16:19:37 +02:00
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)
2020-09-26 16:19:37 +02:00
2022-06-09 07:39:58 +02:00
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:
2020-09-26 16:19:37 +02:00
return int(year)
else:
return int(year) - 1
2023-12-29 13:57:44 +01:00
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)
2023-12-29 13:57:44 +01:00
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()
2022-11-09 12:50:10 +01:00
return datetime.datetime(
year=annee_sco + 1, month=MONTH_DEBUT_ANNEE_SCOLAIRE, day=1
) - datetime.timedelta(days=1)
2020-09-26 16:19:37 +02:00
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"""
2021-02-03 22:00:41 +01:00
return re.match(r"^.+@.+\..{2,3}$", email)
2020-09-26 16:19:37 +02:00
def graph_from_edges(edges, graph_name="mygraph"):
"""Crée un graph pydot
à partir d'une liste d'arêtes [ (n1, n2), (n2, n3), ... ]
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
2020-09-26 16:19:37 +02:00
ICONSIZES = {} # name : (width, height) cache image sizes
2021-09-10 21:12:59 +02:00
def icontag(name, file_format="png", no_size=False, **attrs):
2020-09-26 16:19:37 +02:00
"""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
2020-09-26 16:19:37 +02:00
pour la mesurer (et cache le résultat).
"""
2021-09-10 21:12:59 +02:00
if (not no_size) and (("width" not in attrs) or ("height" not in attrs)):
2020-09-26 16:19:37 +02:00
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]
2020-09-26 16:19:37 +02:00
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}" />'
2020-09-26 16:19:37 +02:00
ICON_PDF = icontag("pdficon16x20_img", title="Version PDF")
ICON_XLS = icontag("xlsicon_img", title="Export tableur (xlsx)")
2020-09-26 16:19:37 +02:00
2024-03-23 10:17:49 +01:00
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 = "&#9888;&#65039;" # warning /!\
2022-04-03 16:20:16 +02:00
EMO_RED_TRIANGLE_DOWN = "&#128315;" # red triangle pointed down
2022-06-29 22:53:56 +02:00
EMO_PREV_ARROW = "&#10094;"
EMO_NEXT_ARROW = "&#10095;"
2020-09-26 16:19:37 +02:00
2022-01-05 01:03:25 +01:00
def heterogeneous_sorting_key(x):
"key to sort non homogeneous sequences"
return (float(x), "") if isinstance(x, (bool, float, int)) else (-1e34, str(x))
2020-09-26 16:19:37 +02:00
def query_portal(req, msg="Portail Apogee", timeout=3):
2020-10-14 12:36:18 +02:00
"""Retreives external data using HTTP request
2020-09-26 16:19:37 +02:00
(used to connect to Apogee portal, or ScoDoc server)
2020-10-14 12:36:18 +02:00
returns a string, "" on error
2020-09-26 16:19:37 +02:00
"""
log("query_portal: %s" % req)
error_message = None
2020-09-26 16:19:37 +02:00
try:
2021-08-21 00:49:36 +02:00
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}")
2020-09-26 16:19:37 +02:00
return ""
2021-08-21 00:49:36 +02:00
if r.status_code != 200:
log(f"query_portal: http error {r.status_code}")
return ""
2020-09-26 16:19:37 +02:00
2021-08-21 00:49:36 +02:00
return r.text
2020-09-26 16:19:37 +02:00
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",
2024-08-24 08:06:46 +02:00
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}
2024-08-25 07:23:36 +02:00
<div class="form-group space-before-24">
""",
]
2021-09-27 22:54:58 +02:00
if OK or not cancel_url:
2024-08-25 07:23:36 +02:00
H.append(f'<input class="btn btn-default" type="submit" value="{OK}"/>')
if cancel_url:
H.append(
2024-08-25 07:23:36 +02:00
f"""<input class="btn btn-default" type="submit" name="cancel" type ="button" value="{cancel_label}"
onClick="event.preventDefault(); document.location='{cancel_url}';"/>"""
)
2024-08-25 07:23:36 +02:00
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>")
2021-07-31 18:01:10 +02:00
if add_headers:
2024-08-24 08:06:46 +02:00
return render_template(template, content="\n".join(H))
else:
return "\n".join(H)
2021-12-17 13:42:39 +01:00
def objects_renumber(db, obj_list) -> None:
"""fixe les numeros des objets d'une liste de modèles
pour ne pas changer son ordre"""
2023-08-30 16:03:36 +02:00
log("objects_renumber")
for i, obj in enumerate(obj_list):
obj.numero = i
db.session.add(obj)
db.session.commit()
2023-08-30 16:03:36 +02:00
def comp_ranks(tab: list[tuple]) -> dict[int, str]:
2023-02-20 21:04:29 +01:00
"""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 } 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
2023-08-30 16:03:36 +02:00
for i, row in enumerate(tab):
2023-02-20 21:04:29 +01:00
# test ex-aequo
2023-08-30 16:03:36 +02:00
if i < len(tab) - 1:
next_val = tab[i + 1][0]
2023-02-20 21:04:29 +01:00
else:
2023-08-30 16:03:36 +02:00
next_val = None
moy = row[0]
2023-02-20 21:04:29 +01:00
if nb_ex:
srang = "%d ex" % (i + 1 - nb_ex)
2023-08-30 16:03:36 +02:00
if moy == next_val:
2023-02-20 21:04:29 +01:00
nb_ex += 1
else:
nb_ex = 0
else:
2023-08-30 16:03:36 +02:00
if moy == next_val:
2023-02-20 21:04:29 +01:00
srang = "%d ex" % (i + 1 - nb_ex)
nb_ex = 1
else:
srang = "%d" % (i + 1)
2023-08-30 16:03:36 +02:00
rangs[row[-1]] = srang
2023-02-20 21:04:29 +01:00
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"'
2022-06-29 16:30:01 +02:00
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")
2023-01-29 21:52:39 +01:00
if row.get("etudid", "") == selected_etudid:
klass += " row_selected"
tr_class = f'class="{klass}"' if klass else ""
2023-01-29 21:52:39 +01:00
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
)
2023-11-22 15:53:25 +01:00
2023-12-05 21:04:38 +01:00
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...
2023-11-22 15:53:25 +01:00
from app.models import ScoDocSiteConfig
2023-11-22 15:53:25 +01:00
match config_type:
2023-12-05 21:04:38 +01:00
case "matin" | "assi_morning_time":
2023-11-22 15:53:25 +01:00
return ScoDocSiteConfig.get("assi_morning_time", "08:00:00")
2023-12-05 21:04:38 +01:00
case "aprem" | "assi_afternoon_time":
2023-11-22 15:53:25 +01:00
return ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00")
2023-12-05 21:04:38 +01:00
case "pivot" | "assi_lunch_time":
2023-11-22 15:53:25 +01:00
return ScoDocSiteConfig.get("assi_lunch_time", "13:00:00")
2023-12-05 21:04:38 +01:00
case "tick" | "assi_tick_time":
return ScoDocSiteConfig.get("assi_tick_time", 15)
raise ValueError(f"invalid config_type: {config_type}")