# -*- 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, 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.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", } 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, "") 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 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 class MultiSelect: """ Classe pour faciliter l'utilisation du multi-select HTML/JS Les values sont représentées en dict { value: "...", label:"...", selected: True/False (default to False), single: True/False (default to False) } Args: values (dict[str, list[dict]]): Dictionnaire des valeurs génère des pour chaque clef du dictionnaire génère des