# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2023 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 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.codes_cursus import NOTES_TOLERANCE, CODES_EXPL 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) 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", } # 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 # 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", "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 "" 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://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 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 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): "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, "0": False, "1": True, "f": False, "false": False, "n": False, "t": True, "true": 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, 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 >= 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_anne_scolaire(annee_sco: int) -> datetime: """La date de début de l'année scolaire (par défaut, le 1er aout) """ return datetime.datetime(year=annee_sco, month=MONTH_DEBUT_ANNEE_SCOLAIRE, day=1) def date_fin_anne_scolaire(annee_sco: int) -> 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 retre 1 jour. # On s'affranchit ainsi des problèmes de durées de mois. 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, ), ) 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) 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", ): """HTML confirmation dialog: submit (POST) to same page or dest_url if given.""" from app.scodoc import html_sco_header 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} """, ] if OK or not cancel_url: H.append(f'<input type="submit" value="{OK}"/>') if cancel_url: H.append( f"""<input type ="button" value="{cancel_label}" onClick="document.location='{cancel_url}';"/>""" ) 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 ( 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") for i, obj in enumerate(obj_list): obj.numero = i db.session.add(obj) db.session.commit() def comp_ranks(T: 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 in range(len(T)): # test ex-aequo if i < len(T) - 1: next = T[i + 1][0] else: next = None moy = T[i][0] if nb_ex: srang = "%d ex" % (i + 1 - nb_ex) if moy == next: nb_ex += 1 else: nb_ex = 0 else: if moy == next: srang = "%d ex" % (i + 1 - nb_ex) nb_ex = 1 else: srang = "%d" % (i + 1) rangs[T[i][-1]] = srang # str(i+1) 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()