# -*- 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, models 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(models.ScoDocModel): """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_uid_use_scodoc": bool, "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 cas_uid_use_scodoc(cls) -> bool: """True si cas_uid_use_scodoc""" cfg = ScoDocSiteConfig.query.filter_by(name="cas_uid_use_scodoc").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 bulletins""" 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 mail, 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}