# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Emmanuel Viennet emmanuel.viennet@viennet.net # ############################################################################## """Common definitions""" import base64 import bisect import collections import datetime from enum import IntEnum, Enum import io import json from hashlib import md5 import numbers import os import re from shutil import get_terminal_size import _thread import time import unicodedata import urllib from urllib.parse import urlparse, parse_qsl, urlunparse, urlencode import numpy as np from PIL import Image as PILImage import pydot import requests from pytz import timezone import flask from flask import g, render_template, request, Response from flask import flash, make_response from flask_json import json_response from werkzeug.http import HTTP_STATUS_CODES from config import Config from app import log, ScoDocJSONEncoder from app.forms.multiselect import MultiSelect from app.scodoc.codes_cursus import NOTES_TOLERANCE, CODES_EXPL from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_xml import sco_version # En principe, aucun champ text ne devrait excéder cette taille MAX_TEXT_LEN = 64 * 1024 # le répertoire static, lié à chaque release pour éviter les problèmes de caches STATIC_DIR = ( os.environ.get("SCRIPT_NAME", "") + "/ScoDoc/static/links/" + sco_version.SCOVERSION ) # Attention: suppose que la timezone utilisée par postgresql soit la même ! TIME_ZONE = timezone("/".join(os.path.realpath("/etc/localtime").split("/")[-2:])) "La timezone du serveur" # ----- CIVILITE ETUDIANTS CIVILITES = {"M": "M.", "F": "Mme", "X": ""} CIVILITES_ETAT_CIVIL = {"M": "M.", "F": "Mme"} # Si l'état civil reconnait le genre neutre (X),: # CIVILITES_ETAT_CIVIL = CIVILITES # ----- CALCUL ET PRESENTATION DES NOTES NOTES_PRECISION = 1e-4 # evite eventuelles erreurs d'arrondis NOTES_MIN = 0.0 # valeur minimale admise pour une note (sauf malus, dans [-20, 20]) NOTES_MAX = 1000.0 NOTES_ABSENCE = -999.0 # absences dans les DataFrames, NULL en base NOTES_NEUTRALISE = -1000.0 # notes non prises en comptes dans moyennes NOTES_SUPPRESS = -1001.0 # note a supprimer NOTES_ATTENTE = -1002.0 # note "en attente" (se calcule comme une note neutralisee) NO_NOTE_STR = "-" # contenu des cellules de tableaux html sans notes # ---- CODES INSCRIPTION AUX SEMESTRES # (champ etat de FormSemestreInscription) INSCRIT = "I" DEMISSION = "D" DEF = "DEF" ETATS_INSCRIPTION = { INSCRIT: "Inscrit", DEMISSION: "Démission", DEF: "Défaillant", } ETATS_INSCRIPTION_SHORT = { INSCRIT: "I", DEMISSION: "DEM", DEF: "DEF", } def convert_fr_date( date_str: str | datetime.datetime, allow_iso=True ) -> datetime.datetime: """Converti une date saisie par un humain français avant 2070 en un objet datetime. 12/2/1972 => 1972-02-12, 12/2/72 => 1972-02-12, mais 12/2/24 => 2024-02-12 Le pivot est 70. ScoValueError si date invalide. """ if isinstance(date_str, datetime.datetime): return date_str try: return datetime.datetime.strptime(date_str, DATE_FMT) except ValueError: # Try to add century ? m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d\d)$", date_str) if m: year = int(m.group(3)) if year < 70: year += 2000 else: year += 1900 try: return datetime.datetime.strptime( f"{m.group(1)}/{m.group(2)}/{year}", DATE_FMT ) except ValueError: pass if allow_iso: try: return datetime.datetime.fromisoformat(date_str) except ValueError as exc: raise ScoValueError("Date (j/m/a or ISO) invalide") from exc raise ScoValueError("Date (j/m/a) invalide") def print_progress_bar( iteration, total, prefix="", suffix="", finish_msg="", decimals=1, length=100, fill="█", autosize=False, ): """ Affiche une progress bar à un point donné (mettre dans une boucle pour rendre dynamique) @params: iteration - Required : index du point donné (Int) total - Required : nombre total avant complétion (eg: len(List)) prefix - Optional : Préfix -> écrit à gauche de la barre (Str) suffix - Optional : Suffix -> écrit à droite de la barre (Str) decimals - Optional : nombres de chiffres après la virgule (Int) length - Optional : taille de la barre en nombre de caractères (Int) fill - Optional : charactère de remplissange de la barre (Str) autosize - Optional : Choisir automatiquement la taille de la barre en fonction du terminal (Bool) """ percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) color = TerminalColor.RED if 50 >= float(percent) > 25: color = TerminalColor.MAGENTA if 75 >= float(percent) > 50: color = TerminalColor.BLUE if 90 >= float(percent) > 75: color = TerminalColor.CYAN if 100 >= float(percent) > 90: color = TerminalColor.GREEN styling = f"{prefix} |{fill}| {percent}% {suffix}" if autosize: cols, _ = get_terminal_size(fallback=(length, 1)) length = cols - len(styling) filled_length = int(length * iteration // total) pg_bar = fill * filled_length + "-" * (length - filled_length) print(f"\r{color}{styling.replace(fill, pg_bar)}{TerminalColor.RESET}", end="\r") # Affiche une nouvelle ligne vide if iteration == total: print(f"\n{finish_msg}") class TerminalColor: """Ensemble de couleur pour terminaux""" BLUE = "\033[94m" CYAN = "\033[96m" GREEN = "\033[92m" MAGENTA = "\033[95m" RED = "\033[91m" RESET = "\033[0m" class BiDirectionalEnum(Enum): """Permet la recherche inverse d'un enum Condition : les clés et les valeurs doivent être uniques les clés doivent être en MAJUSCULES => (respect de la convention des constantes) """ @classmethod def contains(cls, attr: str): """Vérifie sur un attribut existe dans l'enum""" # Existe dans la classe parent de Enum (EnumType) # pylint: disable-next=no-member return attr.upper() in cls._member_names_ @classmethod def all(cls, keys=True) -> tuple[str | object]: """Retourne toutes les clés de l'enum (en minuscules) ou les valeurs""" return ( tuple( k.lower() # pylint: disable-next=no-member for k in cls._member_names_ ) # renvoie les clés en minuscules if keys else tuple(cls._value2member_map_.keys()) # renvoie les valeurs ) @classmethod def get(cls, attr: str, default: any = None): """Récupère une valeur à partir de son attribut""" val = None try: val = cls[attr.upper()] except (KeyError, AttributeError): val = default return val @classmethod def inverse(cls): """Retourne un dictionnaire représentant la map inverse de l'Enum""" return cls._value2member_map_ class EtatAssiduite(int, BiDirectionalEnum): """Code des états d'assiduité""" # Stockés en BD ne pas modifier PRESENT = 0 RETARD = 1 ABSENT = 2 def version_lisible(self) -> str: """Retourne une version lisible des états d'assiduités Est utilisé pour les vues. """ return { EtatAssiduite.PRESENT: "Présence", EtatAssiduite.ABSENT: "Absence", EtatAssiduite.RETARD: "Retard", }.get(self, "") def e(self) -> str: """e si la version libile est féminine""" return { EtatAssiduite.PRESENT: "e", EtatAssiduite.ABSENT: "e", EtatAssiduite.RETARD: "", }.get(self, "") class EtatJustificatif(int, BiDirectionalEnum): """Code des états des justificatifs""" # Stockés en BD ne pas modifier VALIDE = 0 NON_VALIDE = 1 ATTENTE = 2 MODIFIE = 3 def version_lisible(self) -> str: """Retourne une version lisible des états de justificatifs Est utilisé pour les vues. """ return { EtatJustificatif.VALIDE: "valide", EtatJustificatif.ATTENTE: "soumis", EtatJustificatif.MODIFIE: "modifié", EtatJustificatif.NON_VALIDE: "invalide", }.get(self, "") @classmethod def is_valid_etat(cls, etat: int) -> bool: "True if etat is valid" return etat in cls._value2member_map_ class NonWorkDays(int, BiDirectionalEnum): """Correspondance entre les jours et les numéros de jours""" LUN = 0 MAR = 1 MER = 2 JEU = 3 VEN = 4 SAM = 5 DIM = 6 @classmethod def get_all_non_work_days( cls, formsemestre_id: int = None, dept_id: int = None ) -> list["NonWorkDays"]: """ get_all_non_work_days Récupère la liste des non workdays (str) depuis les préférences puis renvoie une liste BiDirectionnalEnum 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 """ # 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. (fichier global) ou # SCODOC_LOGO_DIR/LOGOS_DIR_PREFIX/LOGO_FILE_PREFIX. (fichier départemental) # ----- Les outils distribués SCO_TOOLS_DIR = os.path.join(Config.SCODOC_DIR, "tools") # ----- Lecture du fichier de configuration from app.scodoc import sco_config from app.scodoc import sco_config_load sco_config_load.load_local_configuration(SCODOC_CFG_DIR) CONFIG = sco_config.CONFIG if hasattr(CONFIG, "CODES_EXPL"): CODES_EXPL.update( CONFIG.CODES_EXPL ) # permet de customiser les explications de codes if CONFIG.CUSTOM_HTML_HEADER: CUSTOM_HTML_HEADER = open(CONFIG.CUSTOM_HTML_HEADER).read() else: CUSTOM_HTML_HEADER = "" if CONFIG.CUSTOM_HTML_HEADER_CNX: CUSTOM_HTML_HEADER_CNX = open(CONFIG.CUSTOM_HTML_HEADER_CNX).read() else: CUSTOM_HTML_HEADER_CNX = "" if CONFIG.CUSTOM_HTML_FOOTER: CUSTOM_HTML_FOOTER = open(CONFIG.CUSTOM_HTML_FOOTER).read() else: CUSTOM_HTML_FOOTER = "" if CONFIG.CUSTOM_HTML_FOOTER_CNX: CUSTOM_HTML_FOOTER_CNX = open(CONFIG.CUSTOM_HTML_FOOTER_CNX).read() else: CUSTOM_HTML_FOOTER_CNX = "" SCO_ENCODING = "utf-8" # used by Excel, XML, PDF, ... SCO_DEFAULT_SQL_USER = "scodoc" # should match Zope process UID SCO_DEFAULT_SQL_PORT = "5432" SCO_DEFAULT_SQL_USERS_CNX = "dbname=SCOUSERS port=%s" % SCO_DEFAULT_SQL_PORT # Valeurs utilisées pour affichage seulement, pas de requetes ni de mails envoyés: SCO_WEBSITE = "https://scodoc.org" SCO_USER_MANUAL = "https://scodoc.org/GuideUtilisateur" SCO_ANNONCES_WEBSITE = "https://scodoc.org/Contact" SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr" SCO_USERS_LIST = "notes@listes.univ-paris13.fr" SCO_LISTS_URL = "https://scodoc.org/Contact" SCO_DISCORD_ASSISTANCE = "https://discord.gg/ybw6ugtFsZ" # Mails avec exceptions (erreurs) anormales envoyés à cette adresse: # mettre '' pour désactiver completement l'envois de mails d'erreurs. # (ces mails sont précieux pour corriger les erreurs, ne les désactiver que si # vous avez de bonnes raisons de le faire: vous pouvez me contacter avant) SCO_EXC_MAIL = "scodoc-exception@viennet.net" # L'adresse du mainteneur (non utilisée automatiquement par ScoDoc: ne pas changer) SCO_DEV_MAIL = "emmanuel.viennet@gmail.com" # SVP ne pas changer # Adresse pour l'envoi des dumps (pour assistance technnique): # ne pas changer (ou vous perdez le support) SCO_DUMP_UP_URL = "https://scodoc.org/scodoc-installmgr/upload-dump" SCO_UP2DATE = "https://scodoc.org/scodoc-installmgr/check_version" SCO_BUG_REPORT_URL = "https://scodoc.org/scodoc-installmgr/report" SCO_ORG_TIMEOUT = 180 # contacts scodoc.org SCO_EXT_TIMEOUT = 180 # appels à des ressources extérieures (siret, ...) SCO_TEST_API_TIMEOUT = 5 # pour tests unitaires API CSV_FIELDSEP = ";" CSV_LINESEP = "\n" CSV_MIMETYPE = "text/comma-separated-values" CSV_SUFFIX = ".csv" DOCX_MIMETYPE = ( "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) DOCX_SUFFIX = ".docx" JSON_MIMETYPE = "application/json" JSON_SUFFIX = ".json" PDF_MIMETYPE = "application/pdf" PDF_SUFFIX = ".pdf" XLS_MIMETYPE = "application/vnd.ms-excel" XLS_SUFFIX = ".xls" XLSX_MIMETYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" XLSX_SUFFIX = ".xlsx" XML_MIMETYPE = "text/xml" XML_SUFFIX = ".xml" # Format pour lesquels on exporte sans formattage des nombres (pas de perte de précision) FORMATS_NUMERIQUES = {"csv", "xls", "xlsx", "xml", "json"} def get_mime_suffix(format_code: str) -> tuple[str, str]: """Returns (MIME, SUFFIX) from format_code == "xls", "xml", ... SUFFIX includes the dot: ".xlsx", ".xml", ... "xls" and "xlsx" format codes give XLSX """ d = { "csv": (CSV_MIMETYPE, CSV_SUFFIX), "docx": (DOCX_MIMETYPE, DOCX_SUFFIX), "xls": (XLSX_MIMETYPE, XLSX_SUFFIX), "xlsx": (XLSX_MIMETYPE, XLSX_SUFFIX), "pdf": (PDF_MIMETYPE, PDF_SUFFIX), "xml": (XML_MIMETYPE, XML_SUFFIX), "json": (JSON_MIMETYPE, JSON_SUFFIX), } return d[format_code] # Admissions des étudiants # Différents types de voies d'admission: # (stocké en texte libre dans la base, mais saisie par menus pour harmoniser) TYPE_ADMISSION_DEFAULT = "Inconnue" TYPES_ADMISSION = ( TYPE_ADMISSION_DEFAULT, "Parcoursup", "Transfert", "APB", "APB-PC", "CEF", "Direct", ) BULLETINS_VERSIONS = { "short": "Version courte", "selectedevals": "Version intermédiaire", "long": "Version complète", } BULLETINS_VERSIONS_BUT = BULLETINS_VERSIONS | { "butcourt": "Version courte spéciale BUT" } # ---- Simple python utilities def simplesqlquote(s, maxlen=50): """simple SQL quoting to avoid most SQL injection attacks. Note: we use this function in the (rare) cases where we have to construct SQL code manually""" s = s[:maxlen] s.replace("'", r"\'") s.replace(";", r"\;") for bad in ("select", "drop", ";", "--", "insert", "delete", "xp_"): s = s.replace(bad, "") return s def unescape_html(s): """un-escape html entities""" s = s.strip().replace("&", "&") s = s.replace("<", "<") s = s.replace(">", ">") return s def build_url_query(url: str, **params) -> str: """Add parameters to existing url, as a query string""" url_parse = urlparse(url) query = url_parse.query url_dict = dict(parse_qsl(query)) url_dict.update(params) url_new_query = urlencode(url_dict) url_parse = url_parse._replace(query=url_new_query) new_url = urlunparse(url_parse) return new_url # test if obj is iterable (but not a string) isiterable = lambda obj: getattr(obj, "__iter__", False) def unescape_html_dict(d): """un-escape all dict values, recursively""" try: indices = list(d.keys()) except: indices = list(range(len(d))) for k in indices: v = d[k] if isinstance(v, bytes): d[k] = unescape_html(v) elif isiterable(v): unescape_html_dict(v) # Expressions used to check noms/prenoms FORBIDDEN_CHARS_EXP = re.compile(r"[*\|~\(\)\\]") ALPHANUM_EXP = re.compile(r"^[\w-]+$", re.UNICODE) def is_valid_code_nip(s: str) -> bool | None: """True si s peut être un code NIP: au moins 6 chiffres décimaux""" if not s: return False return re.match(r"^[0-9]{6,32}$", s) def split_id(ident: str) -> list[str]: """ident est une chaine 'X, Y, Z' Renvoie ['X','Y', 'Z'] """ if ident: ident = ident.strip() return [x.strip() for x in ident.strip().split(",")] if ident else [] return [] def strnone(s): "convert s to string, '' if s is false" if s: return str(s) else: return "" def strip_str(s): "if s is a string, strip it, if is None, do nothing" return s.strip() if s else s def stripquotes(s): "strip s from spaces and quotes" s = s.strip() if s and ((s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'")): s = s[1:-1] return s def suppress_accents(s: str) -> str: "remove accents and suppress non ascii characters from string s" if isinstance(s, str): return ( unicodedata.normalize("NFD", s) .encode("ascii", "ignore") .decode(SCO_ENCODING) ) return s # may be int def normalize_edt_id(edt_id: str) -> str: """Normalize les identifiants edt pour faciliter la correspondance entre les identifiants ScoDoc et ceux dans l'ics: Passe tout en majuscules sans accents ni espaces. """ return ( None if edt_id is None else suppress_accents(edt_id or "").upper().replace(" ", "") ) class PurgeChars: """delete all chars except those belonging to the specified string""" def __init__(self, allowed_chars=""): self.allowed_chars_set = {ord(c) for c in allowed_chars} def __getitem__(self, x): if x not in self.allowed_chars_set: return None raise LookupError() def purge_chars(s, allowed_chars=""): return s.translate(PurgeChars(allowed_chars=allowed_chars)) def sanitize_string(s, remove_spaces=True): """s is an ordinary string, encoding given by SCO_ENCODING" suppress accents and chars interpreted in XML Irreversible (not a quote) For ids and some filenames """ # Table suppressing some chars: to_del = "'`\"<>!&\\ " if remove_spaces else "'`\"<>!&" trans = str.maketrans("", "", to_del) return suppress_accents(s.translate(trans)).replace("\t", "_") _BAD_FILENAME_CHARS = str.maketrans("", "", ":/\\&[]*?'") def make_filename(name): """Try to convert name to a reasonable filename without spaces, (back)slashes, : and without accents """ return ( suppress_accents(name.translate(_BAD_FILENAME_CHARS)).replace(" ", "_") or "scodoc" ) VALID_CARS = ( "-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.!" # no / ! ) VALID_CARS_SET = set(VALID_CARS) VALID_EXP = re.compile("^[" + VALID_CARS + "]+$") def sanitize_filename(filename): """Keep only valid chars used for archives filenames """ filename = suppress_accents(filename.replace(" ", "_")) sane = "".join([c for c in filename if c in VALID_CARS_SET]) if len(sane) < 2: sane = time.strftime("%Y-%m-%d-%H%M%S") + "-" + sane return sane def is_valid_filename(filename): """True if filename is safe""" return VALID_EXP.match(filename) BOOL_STR = { 0: False, 1: True, "": False, "0": False, "1": True, "f": False, "false": False, "o": True, "on": True, "n": False, "t": True, "true": True, True: True, "v": True, "vrai": True, "y": True, } def to_bool(x) -> bool: """Cast value to boolean. The value may be encoded as a string False are: empty, "0", "False", "f", "n". True: all other values, such as "1", "True", "foo", "bar"... Case insentive, ignore leading and trailing spaces. """ if isinstance(x, str): return BOOL_STR.get(x.lower().strip(), True) return bool(x) # Min/Max values for numbers stored in database: DB_MIN_FLOAT = -1e30 DB_MAX_FLOAT = 1e30 DB_MIN_INT = -(1 << 31) DB_MAX_INT = (1 << 31) - 1 def bul_filename_old(sem: dict, etud: dict, fmt): """Build a filename for this bulletin""" dt = time.strftime("%Y-%m-%d") filename = f"bul-{sem['titre_num']}-{dt}-{etud['nom']}.{fmt}" filename = make_filename(filename) return filename def bul_filename(formsemestre, etud, prefix="bul"): """Build a filename for this bulletin (without suffix)""" dt = time.strftime("%Y-%m-%d") filename = f"{prefix}-{formsemestre.titre_num()}-{dt}-{etud.nom}" filename = make_filename(filename) return filename def flash_errors(form): """Flashes form errors (version sommaire)""" for field, _ in form.errors.items(): flash( f"Erreur: voir le champ {getattr(form, field).label.text}", "warning", ) # see https://getbootstrap.com/docs/4.0/components/alerts/ def flash_once(message: str): """Flash the message, but only once per request""" if not hasattr(g, "sco_flashed_once"): g.sco_flashed_once = set() if not message in g.sco_flashed_once: flash(message) g.sco_flashed_once.add(message) def html_flash_message(message: str): """HTML for flashed messaged, for legacy codes""" return f"""
""" 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'' ICON_PDF = icontag("pdficon16x20_img", title="Version PDF") ICON_XLS = icontag("xlsicon_img", title="Export tableur (xlsx)") ICON_PUBLISHED = """Bulletins publiés sur la passerelle étudiants""" ICON_HIDDEN = """Bulletins NON publiés sur la passerelle étudiants""" # HTML emojis EMO_WARNING = "⚠️" # warning /!\ EMO_RED_TRIANGLE_DOWN = "🔻" # red triangle pointed down EMO_PREV_ARROW = "❮" EMO_NEXT_ARROW = "❯" def heterogeneous_sorting_key(x): "key to sort non homogeneous sequences" return (float(x), "") if isinstance(x, (bool, float, int)) else (-1e34, str(x)) def query_portal(req, msg="Portail Apogee", timeout=3): """Retreives external data using HTTP request (used to connect to Apogee portal, or ScoDoc server) returns a string, "" on error """ log("query_portal: %s" % req) error_message = None try: r = requests.get(req, timeout=timeout) # seconds / request except requests.ConnectionError: error_message = "ConnectionError" except requests.Timeout: error_message = "Timeout" except requests.TooManyRedirects: error_message = "TooManyRedirects" except requests.RequestException: error_message = f"can't connect to {msg}" if error_message is not None: log(f"query_portal: {error_message}") return "" if r.status_code != 200: log(f"query_portal: http error {r.status_code}") return "" return r.text def confirm_dialog( message="

Confirmer ?

", 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"""
{message}
""", ] if OK or not cancel_url: H.append(f'') if cancel_url: H.append( f"""""" ) H.append("
") 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"""""") else: H.append( f"""""" ) H.append("
") if help_msg: H.append('

' + help_msg + "

") 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"{content}" return f"<{elt} {attrs}>{content}" 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"""{ "".join([gen_cell(key, row, elt, with_col_class=with_col_classes) for key in keys if not key.startswith('_')]) }""" # 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}")