ScoDoc/app/scodoc/sco_utils.py

1775 lines
53 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")
# Various implementations of heure_to_iso8601 with timing:
# def heure_to_iso8601_v0(t: str | datetime.time = None) -> str: # 682 nsec per loop
# """Convert time string or time object to ISO 8601 (allows 16:03, 16h03, 16),
# returns hh:mm:ss or empty string
# """
# if isinstance(t, datetime.time):
# return t.isoformat()
# if not t:
# return ""
# parts = t.strip().upper().replace("H", ":").split(":")
# # here we can have hh, hh:mm or hh:mm:ss
# h = int(parts[0])
# m = int(parts[1]) if len(parts) > 1 and parts[1].strip() else 0
# s = int(parts[2]) if len(parts) > 2 and parts[1].strip() else 0
# return datetime.time(h, m, s).isoformat()
# def heure_to_iso8601_v1(t: str | datetime.time = None) -> str: # 581 nsec per loop
# """Convert time string or time object to ISO 8601 (allows 16:03, 16h03, 16),
# returns hh:mm:ss or empty string
# """
# if isinstance(t, datetime.time):
# return t.isoformat()
# if not t:
# return ""
# parts = t.strip().upper().replace("H", ":").split(":")
# # here we can have hh, hh:mm or hh:mm:ss
# h = int(parts[0])
# m = int(parts[1]) if len(parts) > 1 and parts[1].strip() else 0
# s = int(parts[2]) if len(parts) > 2 and parts[1].strip() else 0
# return "%02d:%02d:%02d" % (h, m, s)
def heure_to_iso8601(t: str | datetime.time = None) -> str: # 500 nsec per loop
"""Convert time string or time object to ISO 8601 (allows 16:03, 16h03, 16),
returns hh:mm:ss or empty string
"""
if isinstance(t, datetime.time):
return t.isoformat()
if not t:
return ""
parts = t.strip().upper().replace("H", ":").split(":")
# here we can have hh, hh:mm or hh:mm:ss
h = int(parts[0])
try:
m = int(parts[1])
except:
m = 0
try:
s = int(parts[2])
except:
s = 0
return "%02d:%02d:%02d" % (h, m, s)
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("&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
# 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,
) -> Response:
"Réponse XML: data est une liste d'objets"
if not isinstance(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: str, uppercase=True) -> str:
"Formatte le nom"
if not s:
return ""
if uppercase:
return (s.upper()).strip()
return format_prenom(s)
def format_prenom(s: str) -> str:
"""Formatte prenom etudiant pour affichage
Pour les étudiants, 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)).strip()
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 = "&#9888;&#65039;" # warning /!\
EMO_RED_TRIANGLE_DOWN = "&#128315;" # red triangle pointed down
EMO_PREV_ARROW = "&#10094;"
EMO_NEXT_ARROW = "&#10095;"
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}")