forked from ScoDoc/ScoDoc
1183 lines
34 KiB
Python
1183 lines
34 KiB
Python
# -*- mode: python -*-
|
|
# -*- coding: utf-8 -*-
|
|
|
|
##############################################################################
|
|
#
|
|
# Gestion scolarite IUT
|
|
#
|
|
# Copyright (c) 1999 - 2022 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 copy
|
|
import datetime
|
|
from enum import IntEnum
|
|
import io
|
|
import json
|
|
from hashlib import md5
|
|
import numbers
|
|
import os
|
|
import re
|
|
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
|
|
|
|
import flask
|
|
from flask import g, request
|
|
from flask import flash, url_for, make_response, jsonify
|
|
from werkzeug.http import HTTP_STATUS_CODES
|
|
|
|
from config import Config
|
|
from app import log
|
|
from app.scodoc.sco_vdi import ApoEtapeVDI
|
|
from app.scodoc.sco_xml import quote_xml_attr
|
|
from app.scodoc.sco_codes_parcours import NOTES_TOLERANCE, CODES_EXPL
|
|
from app.scodoc import sco_exceptions
|
|
from app.scodoc import sco_xml
|
|
import sco_version
|
|
|
|
# le répertoire static, lié à chaque release pour éviter les problèmes de caches
|
|
STATIC_DIR = "/ScoDoc/static/links/" + sco_version.SCOVERSION
|
|
|
|
# ----- 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)
|
|
|
|
# ---- CODES INSCRIPTION AUX SEMESTRES
|
|
# (champ etat de FormSemestreInscription)
|
|
INSCRIT = "I"
|
|
DEMISSION = "D"
|
|
DEF = "DEF"
|
|
|
|
# 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 ficheEtud (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",
|
|
)
|
|
|
|
EVALUATION_NORMALE = 0
|
|
EVALUATION_RATTRAPAGE = 1
|
|
EVALUATION_SESSION2 = 2
|
|
|
|
MONTH_NAMES_ABBREV = (
|
|
"Jan ",
|
|
"Fév ",
|
|
"Mars",
|
|
"Avr ",
|
|
"Mai ",
|
|
"Juin",
|
|
"Jul ",
|
|
"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")
|
|
|
|
|
|
def fmt_note(val, note_max=None, keep_numeric=False):
|
|
"""conversion note en str pour affichage dans tables HTML ou PDF.
|
|
Si keep_numeric, laisse les valeur numeriques telles quelles (pour export Excel)
|
|
"""
|
|
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 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
|
|
else:
|
|
s = "%2.2f" % round(float(val), 2) # 2 chiffres apres la virgule
|
|
s = "0" * (5 - len(s)) + s # padding: 0 à gauche pour longueur 5: "12.34"
|
|
return s
|
|
else:
|
|
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 ""
|
|
|
|
|
|
class DictDefault(dict): # obsolete, use collections.defaultdict
|
|
"""A dictionnary with default value for all keys
|
|
Each time a non existent key is requested, it is added to the dict.
|
|
(used in python 2.4, can't use new __missing__ method)
|
|
"""
|
|
|
|
defaultvalue = 0
|
|
|
|
def __init__(self, defaultvalue=0, kv_dict={}):
|
|
dict.__init__(self)
|
|
self.defaultvalue = defaultvalue
|
|
self.update(kv_dict)
|
|
|
|
def __getitem__(self, k):
|
|
if k in self:
|
|
return self.get(k)
|
|
value = copy.copy(self.defaultvalue)
|
|
self[k] = value
|
|
return value
|
|
|
|
|
|
class WrapDict(object):
|
|
"""Wrap a dict so that getitem returns '' when values are None"""
|
|
|
|
def __init__(self, adict, NoneValue=""):
|
|
self.dict = adict
|
|
self.NoneValue = NoneValue
|
|
|
|
def __getitem__(self, key):
|
|
value = self.dict[key]
|
|
if value is None:
|
|
return self.NoneValue
|
|
else:
|
|
return value
|
|
|
|
|
|
def group_by_key(d, key):
|
|
gr = DictDefault(defaultvalue=[])
|
|
for e in d:
|
|
gr[e[key]].append(e)
|
|
return gr
|
|
|
|
|
|
# ----- 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://listes.univ-paris13.fr/mailman/listinfo/scodoc-annonces"
|
|
SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr"
|
|
SCO_USERS_LIST = "notes@listes.univ-paris13.fr"
|
|
SCO_LISTS_URL = "https://scodoc.org/ListesDeDiffusion/"
|
|
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"
|
|
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, "APB", "APB-PC", "CEF", "Direct")
|
|
|
|
BULLETINS_VERSIONS = ("short", "selectedevals", "long")
|
|
|
|
# Support for ScoDoc7 compatibility
|
|
|
|
|
|
def ScoURL():
|
|
"""base URL for this sco instance.
|
|
e.g. https://scodoc.xxx.fr/ScoDoc/DEPT/Scolarite
|
|
= page accueil département
|
|
"""
|
|
return url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)[
|
|
: -len("/index_html")
|
|
]
|
|
|
|
|
|
def NotesURL():
|
|
"""URL of Notes
|
|
e.g. https://scodoc.xxx.fr/ScoDoc/DEPT/Scolarite/Notes
|
|
= url de base des méthodes de notes
|
|
(page accueil programmes).
|
|
"""
|
|
return url_for("notes.index_html", scodoc_dept=g.scodoc_dept)[: -len("/index_html")]
|
|
|
|
|
|
def EntreprisesURL():
|
|
"""URL of Enterprises
|
|
e.g. https://scodoc.xxx.fr/ScoDoc/DEPT/Scolarite/Entreprises
|
|
= url de base des requêtes de ZEntreprises
|
|
et page accueil Entreprises
|
|
"""
|
|
return "NotImplemented"
|
|
# url_for("entreprises.index_html", scodoc_dept=g.scodoc_dept)[
|
|
# : -len("/index_html")
|
|
# ]
|
|
|
|
|
|
def AbsencesURL():
|
|
"""URL of Absences"""
|
|
return url_for("absences.index_html", scodoc_dept=g.scodoc_dept)[
|
|
: -len("/index_html")
|
|
]
|
|
|
|
|
|
def UsersURL():
|
|
"""URL of Users
|
|
e.g. https://scodoc.xxx.fr/ScoDoc/DEPT/Scolarite/Users
|
|
= url de base des requêtes ZScoUsers
|
|
et page accueil users
|
|
"""
|
|
return url_for("users.index_html", scodoc_dept=g.scodoc_dept)[: -len("/index_html")]
|
|
|
|
|
|
# ---- Simple python utilities
|
|
|
|
|
|
def simplesqlquote(s, maxlen=50):
|
|
"""simple SQL quoting to avoid most SQL injection attacks.
|
|
Note: we use this function in the (rare) cases where we have to
|
|
construct SQL code manually"""
|
|
s = s[:maxlen]
|
|
s.replace("'", r"\'")
|
|
s.replace(";", r"\;")
|
|
for bad in ("select", "drop", ";", "--", "insert", "delete", "xp_"):
|
|
s = s.replace(bad, "")
|
|
return s
|
|
|
|
|
|
def unescape_html(s):
|
|
"""un-escape html entities"""
|
|
s = s.strip().replace("&", "&")
|
|
s = s.replace("<", "<")
|
|
s = s.replace(">", ">")
|
|
return s
|
|
|
|
|
|
def build_url_query(url: str, **params) -> str:
|
|
"""Add parameters to existing url, as a query string"""
|
|
url_parse = urlparse(url)
|
|
query = url_parse.query
|
|
url_dict = dict(parse_qsl(query))
|
|
url_dict.update(params)
|
|
url_new_query = urlencode(url_dict)
|
|
url_parse = url_parse._replace(query=url_new_query)
|
|
new_url = urlunparse(url_parse)
|
|
return new_url
|
|
|
|
|
|
# test if obj is iterable (but not a string)
|
|
isiterable = lambda obj: getattr(obj, "__iter__", False)
|
|
|
|
|
|
def unescape_html_dict(d):
|
|
"""un-escape all dict values, recursively"""
|
|
try:
|
|
indices = list(d.keys())
|
|
except:
|
|
indices = list(range(len(d)))
|
|
for k in indices:
|
|
v = d[k]
|
|
if isinstance(v, bytes):
|
|
d[k] = unescape_html(v)
|
|
elif isiterable(v):
|
|
unescape_html_dict(v)
|
|
|
|
|
|
# Expressions used to check noms/prenoms
|
|
FORBIDDEN_CHARS_EXP = re.compile(r"[*\|~\(\)\\]")
|
|
ALPHANUM_EXP = re.compile(r"^[\w-]+$", re.UNICODE)
|
|
|
|
|
|
def is_valid_code_nip(s):
|
|
"""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 strnone(s):
|
|
"convert s to string, '' if s is false"
|
|
if s:
|
|
return str(s)
|
|
else:
|
|
return ""
|
|
|
|
|
|
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):
|
|
"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
|
|
|
|
|
|
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 = {
|
|
"": False,
|
|
"false": False,
|
|
"0": False,
|
|
"1": True,
|
|
"true": True,
|
|
}
|
|
|
|
|
|
def to_bool(x) -> bool:
|
|
"""a boolean, may also be encoded as a string "0", "False", "1", "True" """
|
|
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, format):
|
|
"""Build a filename for this bulletin"""
|
|
dt = time.strftime("%Y-%m-%d")
|
|
filename = f"bul-{sem['titre_num']}-{dt}-{etud['nom']}.{format}"
|
|
filename = make_filename(filename)
|
|
return filename
|
|
|
|
|
|
def bul_filename(formsemestre, etud, format):
|
|
"""Build a filename for this bulletin"""
|
|
dt = time.strftime("%Y-%m-%d")
|
|
filename = f"bul-{formsemestre.titre_num()}-{dt}-{etud.nom}.{format}"
|
|
filename = make_filename(filename)
|
|
return filename
|
|
|
|
|
|
def flash_errors(form):
|
|
"""Flashes form errors (version sommaire)"""
|
|
for field, errors in form.errors.items():
|
|
flash(
|
|
"Erreur: voir le champs %s" % (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 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)
|
|
|
|
|
|
class ScoDocJSONEncoder(json.JSONEncoder):
|
|
def default(self, o): # pylint: disable=E0202
|
|
if isinstance(o, (datetime.date, datetime.datetime)):
|
|
return o.isoformat()
|
|
elif isinstance(o, ApoEtapeVDI):
|
|
return str(o)
|
|
else:
|
|
return json.JSONEncoder.default(self, o)
|
|
|
|
|
|
def sendJSON(data, attached=False, filename=None):
|
|
js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder)
|
|
return send_file(
|
|
js, filename=filename or "sco_data.json", mime=JSON_MIMETYPE, attached=attached
|
|
)
|
|
|
|
|
|
def sendXML(
|
|
data,
|
|
tagname=None,
|
|
force_outer_xml_tag=True,
|
|
attached=False,
|
|
quote=False,
|
|
filename=None,
|
|
):
|
|
if type(data) != list:
|
|
data = [data] # always list-of-dicts
|
|
if force_outer_xml_tag:
|
|
data = [{tagname: data}]
|
|
tagname += "_list"
|
|
doc = sco_xml.simple_dictlist2xml(data, tagname=tagname, quote=quote)
|
|
return send_file(
|
|
doc, filename=filename or "sco_data.xml", mime=XML_MIMETYPE, attached=attached
|
|
)
|
|
|
|
|
|
def sendResult(
|
|
data,
|
|
name=None,
|
|
format=None,
|
|
force_outer_xml_tag=True,
|
|
attached=False,
|
|
quote_xml=False,
|
|
filename=None,
|
|
):
|
|
if (format is None) or (format == "html"):
|
|
return data
|
|
elif format == "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 format == "json":
|
|
return sendJSON(data, attached=attached, filename=filename)
|
|
else:
|
|
raise ValueError("invalid format: %s" % format)
|
|
|
|
|
|
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():
|
|
"""returns a dict with request (POST or GET) arguments
|
|
converted to suit legacy Zope style (scodoc7) functions.
|
|
"""
|
|
# 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":
|
|
vals = {}
|
|
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):
|
|
"""Simple JSON response, for errors"""
|
|
payload = {
|
|
"error": HTTP_STATUS_CODES.get(status_code, "Unknown error"),
|
|
"status": status_code,
|
|
}
|
|
if message:
|
|
payload["message"] = message
|
|
response = jsonify(payload)
|
|
response.status_code = status_code
|
|
log(f"Error: {response}")
|
|
return response
|
|
|
|
|
|
def json_ok_response(status_code=200, payload=None):
|
|
"""Simple JSON respons for "success" """
|
|
payload = payload or {"OK": True}
|
|
response = jsonify(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 timedate_human_repr():
|
|
"representation du temps courant pour utilisateur"
|
|
return time.strftime("%d/%m/%Y à %Hh%M")
|
|
|
|
|
|
def annee_scolaire_repr(year, month):
|
|
"""representation de l'annee scolaire : '2009 - 2010'
|
|
à partir d'une date.
|
|
"""
|
|
if month > 7: # apres le 1er aout
|
|
return "%s - %s" % (year, year + 1)
|
|
else:
|
|
return "%s - %s" % (year - 1, year)
|
|
|
|
|
|
def annee_scolaire_debut(year, month) -> int:
|
|
"""Annee scolaire de debut (septembre): heuristique pour l'hémisphère nord..."""
|
|
if int(month) > 7:
|
|
return int(year)
|
|
else:
|
|
return int(year) - 1
|
|
|
|
|
|
def date_debut_anne_scolaire(annee_scolaire: int) -> datetime:
|
|
"""La date de début de l'année scolaire
|
|
= 1er aout
|
|
"""
|
|
return datetime.datetime(year=annee_scolaire, month=8, day=1)
|
|
|
|
|
|
def date_fin_anne_scolaire(annee_scolaire: int) -> datetime:
|
|
"""La date de fin de l'année scolaire
|
|
= 31 juillet de l'année suivante
|
|
"""
|
|
return datetime.datetime(year=annee_scolaire + 1, month=7, day=31)
|
|
|
|
|
|
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,
|
|
),
|
|
)
|
|
im = PILImage.open(img_file)
|
|
width, height = im.size[0], im.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="Version tableur")
|
|
|
|
# HTML emojis
|
|
EMO_WARNING = "⚠️" # warning /!\
|
|
EMO_RED_TRIANGLE_DOWN = "🔻" # red triangle pointed down
|
|
EMO_PREV_ARROW = "❮"
|
|
EMO_NEXT_ARROW = "❯"
|
|
|
|
|
|
def sort_dates(L, reverse=False):
|
|
"""Return sorted list of dates, allowing None items (they are put at the beginning)"""
|
|
mindate = datetime.datetime(datetime.MINYEAR, 1, 1)
|
|
try:
|
|
return sorted(L, key=lambda x: x or mindate, reverse=reverse)
|
|
except:
|
|
# Helps debugging
|
|
log("sort_dates( %s )" % L)
|
|
raise
|
|
|
|
|
|
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)
|
|
|
|
try:
|
|
r = requests.get(req, timeout=timeout) # seconds / request
|
|
except:
|
|
log("query_portal: can't connect to %s" % msg)
|
|
return ""
|
|
if r.status_code != 200:
|
|
log(f"query_portal: http error {r.status_code}")
|
|
return "" # XXX ou raise exception ?
|
|
|
|
return r.text
|
|
|
|
|
|
def AnneeScolaire(sco_year=None) -> int:
|
|
"annee de debut de l'annee scolaire courante"
|
|
if sco_year:
|
|
year = sco_year
|
|
try:
|
|
year = int(year)
|
|
if year > 1900 and year < 2999:
|
|
return year
|
|
except:
|
|
raise sco_exceptions.ScoValueError("invalid sco_year")
|
|
t = time.localtime()
|
|
year, month = t[0], t[1]
|
|
if month < 8: # le "pivot" est le 1er aout
|
|
year = year - 1
|
|
return year
|
|
|
|
|
|
def confirm_dialog(
|
|
message="<p>Confirmer ?</p>",
|
|
OK="OK",
|
|
Cancel="Annuler",
|
|
dest_url="",
|
|
cancel_url="",
|
|
target_variable="dialog_confirmed",
|
|
parameters={},
|
|
add_headers=True, # complete page
|
|
helpmsg=None,
|
|
):
|
|
from app.scodoc import html_sco_header
|
|
|
|
# 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}
|
|
""",
|
|
]
|
|
if OK or not cancel_url:
|
|
H.append(f'<input type="submit" value="{OK}"/>')
|
|
if cancel_url:
|
|
H.append(
|
|
"""<input type ="button" value="%s"
|
|
onClick="document.location='%s';"/>"""
|
|
% (Cancel, cancel_url)
|
|
)
|
|
for param in parameters.keys():
|
|
if parameters[param] is None:
|
|
parameters[param] = ""
|
|
if type(parameters[param]) == type([]):
|
|
for e in parameters[param]:
|
|
H.append('<input type="hidden" name="%s" value="%s"/>' % (param, e))
|
|
else:
|
|
H.append(
|
|
'<input type="hidden" name="%s" value="%s"/>'
|
|
% (param, parameters[param])
|
|
)
|
|
H.append("</form>")
|
|
if helpmsg:
|
|
H.append('<p class="help">' + helpmsg + "</p>")
|
|
if add_headers:
|
|
return (
|
|
html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer()
|
|
)
|
|
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(f"objects_renumber {obj_list}")
|
|
for i, obj in enumerate(obj_list):
|
|
obj.numero = i
|
|
db.session.add(obj)
|
|
db.session.commit()
|
|
|
|
|
|
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 ""
|
|
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")
|
|
tr_class = f'class="{klass}"' if klass else ""
|
|
tr_id = (
|
|
f"""id="row_selected" """ if (row.get("etudid", "") == selected_etudid) else ""
|
|
)
|
|
return f"""<tr {tr_id} {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()
|