forked from ScoDoc/ScoDoc
486 lines
16 KiB
Python
486 lines
16 KiB
Python
# -*- coding: UTF-8 -*
|
|
|
|
"""Model : site config WORK IN PROGRESS #WIP
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import urllib.parse
|
|
|
|
from flask import flash
|
|
from app import current_app, db, log
|
|
from app.comp import bonus_spo
|
|
from app.scodoc.sco_exceptions import ScoValueError
|
|
from app.scodoc import sco_utils as scu
|
|
|
|
from app.scodoc.codes_cursus import (
|
|
ABAN,
|
|
ABL,
|
|
ADC,
|
|
ADJ,
|
|
ADJR,
|
|
ADM,
|
|
ADSUP,
|
|
AJ,
|
|
ATB,
|
|
ATJ,
|
|
ATT,
|
|
CMP,
|
|
DEF,
|
|
DEM,
|
|
EXCLU,
|
|
NAR,
|
|
PASD,
|
|
PAS1NCI,
|
|
RAT,
|
|
RED,
|
|
)
|
|
|
|
CODES_SCODOC_TO_APO = {
|
|
ABAN: "ABAN",
|
|
ABL: "ABL",
|
|
ADC: "ADMC",
|
|
ADJ: "ADM",
|
|
ADJR: "ADM",
|
|
ADM: "ADM",
|
|
ADSUP: "ADM",
|
|
AJ: "AJ",
|
|
ATB: "AJAC",
|
|
ATJ: "AJAC",
|
|
ATT: "AJAC",
|
|
CMP: "COMP",
|
|
DEF: "NAR",
|
|
DEM: "NAR",
|
|
EXCLU: "EXC",
|
|
NAR: "NAR",
|
|
PASD: "PASD",
|
|
PAS1NCI: "PAS1NCI",
|
|
RAT: "ATT",
|
|
RED: "RED",
|
|
"NOTES_FMT": "%3.2f",
|
|
}
|
|
|
|
|
|
def code_scodoc_to_apo_default(code):
|
|
"""Conversion code jury ScoDoc en code Apogée
|
|
(codes par défaut, c'est configurable via ScoDocSiteConfig.get_code_apo)
|
|
"""
|
|
return CODES_SCODOC_TO_APO.get(code, "DEF")
|
|
|
|
|
|
class ScoDocSiteConfig(db.Model):
|
|
"""Config. d'un site
|
|
Nouveau en ScoDoc 9: va regrouper les paramètres qui dans les versions
|
|
antérieures étaient dans scodoc_config.py
|
|
"""
|
|
|
|
__tablename__ = "scodoc_site_config"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(128), nullable=False, index=True)
|
|
value = db.Column(db.Text())
|
|
|
|
BONUS_SPORT = "bonus_sport_func_name"
|
|
NAMES = {
|
|
BONUS_SPORT: str,
|
|
"always_require_ine": bool,
|
|
"SCOLAR_FONT": str,
|
|
"SCOLAR_FONT_SIZE": str,
|
|
"SCOLAR_FONT_SIZE_FOOT": str,
|
|
"INSTITUTION_NAME": str,
|
|
"INSTITUTION_ADDRESS": str,
|
|
"INSTITUTION_CITY": str,
|
|
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
|
|
"enable_entreprises": bool,
|
|
"disable_passerelle": bool, # remplace pref. bul_display_publication
|
|
"month_debut_annee_scolaire": int,
|
|
"month_debut_periode2": int,
|
|
"disable_bul_pdf": bool,
|
|
"user_require_email_institutionnel": bool,
|
|
# CAS
|
|
"cas_enable": bool,
|
|
"cas_server": str,
|
|
"cas_login_route": str,
|
|
"cas_logout_route": str,
|
|
"cas_validate_route": str,
|
|
"cas_attribute_id": str,
|
|
"cas_uid_from_mail_regexp": str,
|
|
"cas_edt_id_from_xml_regexp": str,
|
|
# Assiduité
|
|
"morning_time": str,
|
|
"lunch_time": str,
|
|
"afternoon_time": str,
|
|
}
|
|
|
|
def __init__(self, name, value):
|
|
self.name = name
|
|
self.value = value
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>"
|
|
|
|
@classmethod
|
|
def get_dict(cls) -> dict:
|
|
"Returns all data as a dict name = value"
|
|
return {
|
|
c.name: cls.NAMES.get(c.name, lambda x: x)(c.value)
|
|
for c in ScoDocSiteConfig.query.all()
|
|
}
|
|
|
|
@classmethod
|
|
def set_bonus_sport_class(cls, class_name):
|
|
"""Record bonus_sport config.
|
|
If class_name not defined, raise NameError
|
|
"""
|
|
if class_name not in cls.get_bonus_sport_class_names():
|
|
raise NameError("invalid class name for bonus_sport")
|
|
c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first()
|
|
if c:
|
|
log("setting to " + class_name)
|
|
c.value = class_name
|
|
else:
|
|
c = ScoDocSiteConfig(cls.BONUS_SPORT, class_name)
|
|
db.session.add(c)
|
|
db.session.commit()
|
|
|
|
@classmethod
|
|
def get_bonus_sport_class_name(cls):
|
|
"""Get configured bonus function name, or None if None."""
|
|
klass = cls.get_bonus_sport_class_from_name()
|
|
if klass is None:
|
|
return ""
|
|
else:
|
|
return klass.name
|
|
|
|
@classmethod
|
|
def get_bonus_sport_class(cls):
|
|
"""Get configured bonus function, or None if None."""
|
|
return cls.get_bonus_sport_class_from_name()
|
|
|
|
@classmethod
|
|
def get_bonus_sport_class_from_name(cls, class_name=None):
|
|
"""returns bonus class with specified name.
|
|
If name not specified, return the configured function.
|
|
None if no bonus function configured.
|
|
If class_name not found in module bonus_sport, returns None
|
|
and flash a warning.
|
|
"""
|
|
if not class_name: # None or ""
|
|
c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first()
|
|
if c is None:
|
|
return None
|
|
class_name = c.value
|
|
if class_name == "": # pas de bonus défini
|
|
return None
|
|
klass = bonus_spo.get_bonus_class_dict().get(class_name)
|
|
if klass is None:
|
|
flash(
|
|
f"""Fonction de calcul bonus sport inexistante: {class_name}.
|
|
Changez là ou contactez votre administrateur local."""
|
|
)
|
|
return klass
|
|
|
|
@classmethod
|
|
def get_bonus_sport_class_names(cls) -> list:
|
|
"""List available bonus class names
|
|
(starting with empty string to represent "no bonus function").
|
|
"""
|
|
return [""] + sorted(bonus_spo.get_bonus_class_dict().keys())
|
|
|
|
@classmethod
|
|
def get_bonus_sport_class_list(cls) -> list[tuple]:
|
|
"""List available bonus class names
|
|
(starting with empty string to represent "no bonus function").
|
|
"""
|
|
d = bonus_spo.get_bonus_class_dict()
|
|
class_list = [(name, d[name].displayed_name) for name in d]
|
|
class_list.sort(key=lambda x: x[1].replace(" du ", " de "))
|
|
return [("", "")] + class_list
|
|
|
|
@classmethod
|
|
def get_code_apo(cls, code: str) -> str:
|
|
"""La représentation d'un code pour les exports Apogée.
|
|
Par exemple, à l'IUT du H., le code ADM est réprésenté par VAL
|
|
Les codes par défaut sont donnés dans sco_apogee_csv.
|
|
"""
|
|
cfg = ScoDocSiteConfig.query.filter_by(name=code).first()
|
|
if not cfg:
|
|
code_apo = code_scodoc_to_apo_default(code)
|
|
else:
|
|
code_apo = cfg.value
|
|
return code_apo
|
|
|
|
@classmethod
|
|
def get_codes_apo_dict(cls) -> dict[str:str]:
|
|
"Un dict avec code jury : code exporté"
|
|
return {code: cls.get_code_apo(code) for code in CODES_SCODOC_TO_APO}
|
|
|
|
@classmethod
|
|
def set_code_apo(cls, code: str, code_apo: str):
|
|
"""Enregistre nouvelle représentation du code"""
|
|
if code_apo != cls.get_code_apo(code):
|
|
cfg = ScoDocSiteConfig.query.filter_by(name=code).first()
|
|
if cfg is None:
|
|
cfg = ScoDocSiteConfig(code, code_apo)
|
|
else:
|
|
cfg.value = code_apo
|
|
db.session.add(cfg)
|
|
db.session.commit()
|
|
|
|
@classmethod
|
|
def is_cas_enabled(cls) -> bool:
|
|
"""True si on utilise le CAS"""
|
|
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
|
|
return cfg is not None and cfg.value
|
|
|
|
@classmethod
|
|
def is_cas_forced(cls) -> bool:
|
|
"""True si CAS forcé"""
|
|
cfg = ScoDocSiteConfig.query.filter_by(name="cas_force").first()
|
|
return cfg is not None and cfg.value
|
|
|
|
@classmethod
|
|
def is_entreprises_enabled(cls) -> bool:
|
|
"""True si on doit activer le module entreprise"""
|
|
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
|
|
return cfg is not None and cfg.value
|
|
|
|
@classmethod
|
|
def is_passerelle_disabled(cls):
|
|
"""True si on doit cacher les fonctions passerelle ("oeil")."""
|
|
cfg = ScoDocSiteConfig.query.filter_by(name="disable_passerelle").first()
|
|
return cfg is not None and cfg.value
|
|
|
|
@classmethod
|
|
def is_user_require_email_institutionnel_enabled(cls) -> bool:
|
|
"""True si impose saisie email_institutionnel"""
|
|
cfg = ScoDocSiteConfig.query.filter_by(
|
|
name="user_require_email_institutionnel"
|
|
).first()
|
|
return cfg is not None and cfg.value
|
|
|
|
@classmethod
|
|
def is_bul_pdf_disabled(cls) -> bool:
|
|
"""True si on interdit les exports PDF des bulltins"""
|
|
cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
|
|
return cfg is not None and cfg.value
|
|
|
|
@classmethod
|
|
def enable_entreprises(cls, enabled: bool = True) -> bool:
|
|
"""Active (ou déactive) le module entreprises. True si changement."""
|
|
return cls.set("enable_entreprises", "on" if enabled else "")
|
|
|
|
@classmethod
|
|
def disable_passerelle(cls, disabled: bool = True) -> bool:
|
|
"""Désactive (ou active) les fonctions liées à la présence d'une passerelle. True si changement."""
|
|
return cls.set("disable_passerelle", "on" if disabled else "")
|
|
|
|
@classmethod
|
|
def disable_bul_pdf(cls, enabled=True) -> bool:
|
|
"""Interdit (ou autorise) les exports PDF. True si changement."""
|
|
return cls.set("disable_bul_pdf", "on" if enabled else "")
|
|
|
|
@classmethod
|
|
def get(cls, name: str, default: str = "") -> str:
|
|
"Get configuration param; empty string or specified default if unset"
|
|
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
|
if cfg is None:
|
|
return default
|
|
return cls.NAMES.get(name, lambda x: x)(cfg.value or "")
|
|
|
|
@classmethod
|
|
def set(cls, name: str, value: str) -> bool:
|
|
"Set parameter, returns True if change. Commit session."
|
|
value_str = str(value or "").strip()
|
|
if (cls.get(name) or "") != value_str:
|
|
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
|
if cfg is None:
|
|
cfg = ScoDocSiteConfig(name=name, value=value_str)
|
|
else:
|
|
cfg.value = value_str
|
|
current_app.logger.info(
|
|
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{
|
|
'...' if len(cfg.value)>32 else ''}'"""
|
|
)
|
|
db.session.add(cfg)
|
|
db.session.commit()
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def _get_int_field(cls, name: str, default=None) -> int:
|
|
"""Valeur d'un champ integer"""
|
|
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
|
if (cfg is None) or cfg.value is None:
|
|
return default
|
|
return int(cfg.value)
|
|
|
|
@classmethod
|
|
def _set_int_field(
|
|
cls,
|
|
name: str,
|
|
value: int,
|
|
default=None,
|
|
range_values: tuple = (),
|
|
) -> bool:
|
|
"""Set champ integer. True si changement."""
|
|
if value != cls._get_int_field(name, default=default):
|
|
if not isinstance(value, int) or (
|
|
range_values and (value < range_values[0]) or (value > range_values[1])
|
|
):
|
|
raise ValueError("invalid value")
|
|
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
|
if cfg is None:
|
|
cfg = ScoDocSiteConfig(name=name, value=str(value))
|
|
else:
|
|
cfg.value = str(value)
|
|
db.session.add(cfg)
|
|
db.session.commit()
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def get_month_debut_annee_scolaire(cls) -> int:
|
|
"""Mois de début de l'année scolaire."""
|
|
return cls._get_int_field(
|
|
"month_debut_annee_scolaire", scu.MONTH_DEBUT_ANNEE_SCOLAIRE
|
|
)
|
|
|
|
@classmethod
|
|
def get_month_debut_periode2(cls) -> int:
|
|
"""Mois de début de l'année scolaire."""
|
|
return cls._get_int_field("month_debut_periode2", scu.MONTH_DEBUT_PERIODE2)
|
|
|
|
@classmethod
|
|
def set_month_debut_annee_scolaire(
|
|
cls, month: int = scu.MONTH_DEBUT_ANNEE_SCOLAIRE
|
|
) -> bool:
|
|
"""Fixe le mois de début des années scolaires.
|
|
True si changement.
|
|
"""
|
|
if cls._set_int_field(
|
|
"month_debut_annee_scolaire", month, scu.MONTH_DEBUT_ANNEE_SCOLAIRE, (1, 12)
|
|
):
|
|
log(f"set_month_debut_annee_scolaire({month})")
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def set_month_debut_periode2(cls, month: int = scu.MONTH_DEBUT_PERIODE2) -> bool:
|
|
"""Fixe le mois de début des années scolaires.
|
|
True si changement.
|
|
"""
|
|
if cls._set_int_field(
|
|
"month_debut_periode2", month, scu.MONTH_DEBUT_PERIODE2, (1, 12)
|
|
):
|
|
log(f"set_month_debut_periode2({month})")
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def get_perso_links(cls) -> list["PersonalizedLink"]:
|
|
"Return links"
|
|
data_links = cls.get("personalized_links")
|
|
if not data_links:
|
|
return []
|
|
try:
|
|
links_dict = json.loads(data_links)
|
|
except json.decoder.JSONDecodeError as exc:
|
|
# Corrupted data ? erase content
|
|
cls.set("personalized_links", "")
|
|
raise ScoValueError(
|
|
"Attention: liens personnalisés erronés: ils ont été effacés."
|
|
) from exc
|
|
return [PersonalizedLink(**item) for item in links_dict]
|
|
|
|
@classmethod
|
|
def set_perso_links(cls, links: list["PersonalizedLink"] = None):
|
|
"Store all links"
|
|
if not links:
|
|
links = []
|
|
links_dict = [link.to_dict() for link in links]
|
|
data_links = json.dumps(links_dict)
|
|
cls.set("personalized_links", data_links)
|
|
|
|
@classmethod
|
|
def extract_cas_id(cls, email_addr: str) -> str | None:
|
|
"Extract cas_id from maill, using regexp in config. None if not possible."
|
|
exp = cls.get("cas_uid_from_mail_regexp")
|
|
if not exp or not email_addr:
|
|
return None
|
|
try:
|
|
match = re.search(exp, email_addr)
|
|
except re.error:
|
|
log("error extracting CAS id from '{email_addr}' using regexp '{exp}'")
|
|
return None
|
|
if not match:
|
|
log("no match extracting CAS id from '{email_addr}' using regexp '{exp}'")
|
|
return None
|
|
try:
|
|
cas_id = match.group(1)
|
|
except IndexError:
|
|
log(
|
|
"no group found extracting CAS id from '{email_addr}' using regexp '{exp}'"
|
|
)
|
|
return None
|
|
return cas_id
|
|
|
|
@classmethod
|
|
def cas_uid_from_mail_regexp_is_valid(cls, exp: str) -> bool:
|
|
"True si l'expression régulière semble valide"
|
|
# check that it compiles
|
|
try:
|
|
pattern = re.compile(exp)
|
|
except re.error:
|
|
return False
|
|
# and returns at least one group on a simple cannonical address
|
|
match = pattern.search("emmanuel@exemple.fr")
|
|
return match is not None and len(match.groups()) > 0
|
|
|
|
@classmethod
|
|
def cas_edt_id_from_xml_regexp_is_valid(cls, exp: str) -> bool:
|
|
"True si l'expression régulière semble valide"
|
|
# check that it compiles
|
|
try:
|
|
_ = re.compile(exp)
|
|
except re.error:
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def assi_get_rounded_time(cls, label: str, default: str) -> float:
|
|
"Donne l'heure stockée dans la config globale sous label, en float arrondi au quart d'heure"
|
|
return _round_time_str_to_quarter(cls.get(label, default))
|
|
|
|
|
|
def _round_time_str_to_quarter(string: str) -> float:
|
|
"""Prend une heure iso '12:20:23', et la converti en un nombre d'heures
|
|
en arrondissant au quart d'heure: (les secondes sont ignorées)
|
|
"12:20:00" -> 12.25
|
|
"12:29:00" -> 12.25
|
|
"12:30:00" -> 12.5
|
|
"""
|
|
parts = [*map(float, string.split(":"))]
|
|
hour = parts[0]
|
|
minutes = round(parts[1] / 60 * 4) / 4
|
|
return hour + minutes
|
|
|
|
|
|
class PersonalizedLink:
|
|
def __init__(self, title: str = "", url: str = "", with_args: bool = False):
|
|
self.title = str(title or "")
|
|
self.url = str(url or "")
|
|
self.with_args = bool(with_args)
|
|
|
|
def get_url(self, params: dict = {}) -> str:
|
|
if not self.with_args:
|
|
return self.url
|
|
query_string = urllib.parse.urlencode(params)
|
|
if "?" in self.url:
|
|
return self.url + "&" + query_string
|
|
return self.url + "?" + query_string
|
|
|
|
def to_dict(self) -> dict:
|
|
"as dict"
|
|
return {"title": self.title, "url": self.url, "with_args": self.with_args}
|