diff --git a/app/__init__.py b/app/__init__.py index 0943a91f6..969c97666 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -33,6 +33,7 @@ from app.scodoc.sco_exceptions import ( ) from config import DevConfig import sco_version +from flask_debugtoolbar import DebugToolbarExtension db = SQLAlchemy() migrate = Migrate(compare_type=True) @@ -187,6 +188,7 @@ def create_app(config_class=DevConfig): moment.init_app(app) cache.init_app(app) sco_cache.CACHE = cache + toolbar = DebugToolbarExtension(app) app.register_error_handler(ScoGenError, handle_sco_value_error) app.register_error_handler(ScoValueError, handle_sco_value_error) diff --git a/app/scodoc/sco_config_actions.py b/app/scodoc/sco_config_actions.py new file mode 100644 index 000000000..bf633a261 --- /dev/null +++ b/app/scodoc/sco_config_actions.py @@ -0,0 +1,181 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2021 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 +# +############################################################################## + +""" +Module main: page d'accueil, avec liste des départements + +Emmanuel Viennet, 2021 +""" +from app.models import ScoDocSiteConfig +from app.scodoc.sco_logos import write_logo, find_logo, delete_logo +import app +from flask import current_app + + +class Action: + """Base class for all classes describing an action from from config form.""" + + def __init__(self, message, parameters): + self.message = message + self.parameters = parameters + + @staticmethod + def build_action(parameters, stream=None): + """Check (from parameters) if some action has to be done and + then return list of action (or else return empty list).""" + raise NotImplementedError + + def display(self): + """return a str describing the action to be done""" + return self.message.format_map(self.parameters) + + def execute(self): + """Executes the action""" + raise NotImplementedError + + +GLOBAL = "_" + + +class LogoUpdate(Action): + """Action: change a logo + dept_id: dept_id or '_', + logo_id: logo_id, + upload: image file replacement + """ + + def __init__(self, parameters): + super().__init__( + f"Modification du logo {parameters['logo_id']} pour le département {parameters['dept_id']}", + parameters, + ) + + @staticmethod + def build_action(parameters): + dept_id = parameters["dept_key"] + if dept_id == GLOBAL: + dept_id = None + parameters["dept_id"] = dept_id + if parameters["upload"] is not None: + return LogoUpdate(parameters) + return None + + def execute(self): + current_app.logger.info(self.message) + write_logo( + stream=self.parameters["upload"], + dept_id=self.parameters["dept_id"], + name=self.parameters["logo_id"], + ) + + +class LogoDelete(Action): + """Action: Delete an existing logo + dept_id: dept_id or '_', + logo_id: logo_id + """ + + def __init__(self, parameters): + super().__init__( + f"Suppression du logo {parameters['logo_id']} pour le département {parameters['dept_id']}.", + parameters, + ) + + @staticmethod + def build_action(parameters): + parameters["dept_id"] = parameters["dept_key"] + if parameters["dept_key"] == GLOBAL: + parameters["dept_id"] = None + if parameters["do_delete"]: + return LogoDelete(parameters) + return None + + def execute(self): + current_app.logger.info(self.message) + delete_logo(name=self.parameters["logo_id"], dept_id=self.parameters["dept_id"]) + + +class LogoInsert(Action): + """Action: add a new logo + dept_key: dept_id or '_', + logo_id: logo_id, + upload: image file replacement + """ + + def __init__(self, parameters): + super().__init__( + f"Ajout du logo {parameters['name']} pour le département {parameters['dept_key']} ({parameters['upload']}).", + parameters, + ) + + @staticmethod + def build_action(parameters): + if parameters["dept_key"] == GLOBAL: + parameters["dept_id"] = None + if parameters["upload"] and parameters["name"]: + logo = find_logo( + logoname=parameters["name"], dept_id=parameters["dept_key"] + ) + if logo is None: + return LogoInsert(parameters) + return None + + def execute(self): + dept_id = self.parameters["dept_key"] + if dept_id == GLOBAL: + dept_id = None + current_app.logger.info(self.message) + write_logo( + stream=self.parameters["upload"], + name=self.parameters["name"], + dept_id=dept_id, + ) + + +class BonusSportUpdate(Action): + """Action: Change bonus_sport_function_name. + bonus_sport_function_name: the new value""" + + def __init__(self, parameters): + super().__init__( + f"Changement du calcul de bonus sport pour ({parameters['bonus_sport_func_name']}).", + parameters, + ) + + @staticmethod + def build_action(parameters): + if ( + parameters["bonus_sport_func_name"] + != ScoDocSiteConfig.get_bonus_sport_func_name() + ): + return [BonusSportUpdate(parameters)] + return [] + + def execute(self): + current_app.logger.info(self.message) + ScoDocSiteConfig.set_bonus_sport_func(self.parameters["bonus_sport_func_name"]) + app.clear_scodoc_cache() diff --git a/app/scodoc/sco_config_form.py b/app/scodoc/sco_config_form.py new file mode 100644 index 000000000..d5ee87a9d --- /dev/null +++ b/app/scodoc/sco_config_form.py @@ -0,0 +1,402 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2021 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 +# +############################################################################## + +""" +Module main: page d'accueil, avec liste des départements + +Emmanuel Viennet, 2021 +""" +import re + +from flask import flash, url_for, redirect, render_template +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed +from wtforms import SelectField, SubmitField, FormField, validators, FieldList +from wtforms.fields.simple import BooleanField, StringField, HiddenField + +from app import AccessDenied +from app.models import Departement +from app.models import ScoDocSiteConfig +from app.scodoc import sco_logos, html_sco_header +from app.scodoc import sco_utils as scu +from app.scodoc.sco_config_actions import ( + LogoDelete, + LogoUpdate, + LogoInsert, + BonusSportUpdate, +) + +from flask_login import current_user + +from app.scodoc.sco_logos import find_logo + +JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [] + +CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS + +# class ItemForm(FlaskForm): +# """Unused Generic class to document common behavior for classes +# * ScoConfigurationForm +# * DeptForm +# * LogoForm +# Some or all of these implements: +# * Composite design pattern (ScoConfigurationForm and DeptForm) +# - a FieldList(FormField(ItemForm)) +# - FieldListItem are created by browsing the model +# - index dictionnary to provide direct access to a SubItemForm +# - the direct access method (get_form) +# * have some information added to be displayed +# - information are collected from a model object +# Common methods: +# * build(model) (not for LogoForm who has no child) +# for each child: +# * create en entry in the FieldList for each subitem found +# * update self.index +# * fill_in additional information into the form +# * recursively calls build for each chid +# some spécific information may be added after standard processing +# (typically header/footer description) +# * preview(data) +# check the data from a post and build a list of operations that has to be done. +# for a two phase process: +# * phase 1 (list all opérations) +# * phase 2 (may be confirmation and execure) +# - if no op found: return to the form with a message 'Aucune modification trouvée' +# - only one operation found: execute and go to main page +# - more than 1 operation found. asked form confirmation (and execution if confirmed) +# +# Someday we'll have time to refactor as abstract classes but Abstract FieldList makes this a bit complicated +# """ + +# Terminology: +# dept_id : identifies a dept in modele (= list_logos()). None designates globals logos +# dept_key : identifies a dept in this form only (..index[dept_key], and fields 'dept_key'). +# 'GLOBAL' designates globals logos (we need a string value to set up HiddenField +GLOBAL = "_" + + +def dept_id_to_key(dept_id): + if dept_id is None: + return GLOBAL + return dept_id + + +def dept_key_to_id(dept_key): + if dept_key == GLOBAL: + return None + return dept_key + + +class AddLogoForm(FlaskForm): + """Formulaire permettant l'ajout d'un logo (dans un département)""" + + dept_key = HiddenField() + name = StringField( + label="Nom", + validators=[ + validators.regexp( + r"^[a-zA-Z0-9-]*$", + re.IGNORECASE, + "Ne doit comporter que lettres, chiffres ou -", + ), + validators.Length( + max=20, message="Un nom ne doit pas dépasser 20 caractères" + ), + validators.required("Nom de logo requis (alphanumériques ou '-')"), + ], + ) + upload = FileField( + label="Sélectionner l'image", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {', '.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", + ), + validators.required("Fichier image manquant"), + ], + ) + do_insert = SubmitField("ajouter une image") + + def __init__(self, *args, **kwargs): + kwargs["meta"] = {"csrf": False} + super().__init__(*args, **kwargs) + + def validate_name(self, name): + dept_id = dept_key_to_id(self.dept_key.data) + if dept_id == GLOBAL: + dept_id = None + if find_logo(logoname=name.data, dept_id=dept_id) is not None: + raise validators.ValidationError("Un logo de même nom existe déjà") + + def select_action(self): + if self.data["do_insert"]: + if self.validate(): + return LogoInsert.build_action(self.data) + return None + + +class LogoForm(FlaskForm): + """Embed both presentation of a logo (cf. template file configuration.html) + and all its data and UI action (change, delete)""" + + dept_key = HiddenField() + logo_id = HiddenField() + upload = FileField( + label="Remplacer l'image", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {', '.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", + ) + ], + ) + do_delete = SubmitField("Supprimer l'image") + + def __init__(self, *args, **kwargs): + kwargs["meta"] = {"csrf": False} + super().__init__(*args, **kwargs) + self.logo = find_logo( + logoname=self.logo_id.data, dept_id=dept_key_to_id(self.dept_key.data) + ).select() + self.description = None + self.titre = None + self.can_delete = True + if self.dept_key.data == GLOBAL: + if self.logo_id.data == "header": + self.can_delete = False + self.description = "" + self.titre = "Logo en-tête" + if self.logo_id.data == "footer": + self.can_delete = False + self.titre = "Logo pied de page" + self.description = "" + else: + if self.logo_id.data == "header": + self.description = "Se substitue au header défini au niveau global" + self.titre = "Logo en-tête" + if self.logo_id.data == "footer": + self.description = "Se substitue au footer défini au niveau global" + self.titre = "Logo pied de page" + + def select_action(self): + if self.do_delete.data and self.can_delete: + return LogoDelete.build_action(self.data) + if self.upload.data and self.validate(): + return LogoUpdate.build_action(self.data) + return None + + +class DeptForm(FlaskForm): + dept_key = HiddenField() + dept_name = HiddenField() + add_logo = FormField(AddLogoForm) + logos = FieldList(FormField(LogoForm)) + + def __init__(self, *args, **kwargs): + kwargs["meta"] = {"csrf": False} + super().__init__(*args, **kwargs) + + def is_local(self): + if self.dept_key.data == GLOBAL: + return None + return True + + def select_action(self): + action = self.add_logo.form.select_action() + if action: + return action + for logo_entry in self.logos.entries: + logo_form = logo_entry.form + action = logo_form.select_action() + if action: + return action + return None + + def get_form(self, logoname=None): + """Retourne le formulaire associé à un logo. None si pas trouvé""" + if logoname is None: # recherche de département + return self + return self.index.get(logoname, None) + + +def _make_dept_id_name(): + """Cette section assute que tous les départements sont traités (y compris ceux qu'ont pas de logo au départ) + et détermine l'ordre d'affichage des DeptForm (GLOBAL d'abord, puis par ordre alpha de nom de département) + -> [ (None, None), (dept_id, dept_name)... ]""" + depts = [(None, GLOBAL)] + for dept in ( + Departement.query.filter_by(visible=True).order_by(Departement.acronym).all() + ): + depts.append((dept.id, dept.acronym)) + return depts + + +def _ordered_logos(modele): + """sort logoname alphabetically but header and footer moved at start. (since there is no space in logoname)""" + + def sort(name): + if name == "header": + return " 0" + if name == "footer": + return " 1" + return name + + order = sorted(modele.keys(), key=sort) + return order + + +def _make_dept_data(dept_id, dept_name, modele): + dept_key = dept_id_to_key(dept_id) + data = { + "dept_key": dept_key, + "dept_name": dept_name, + "add_logo": {"dept_key": dept_key}, + } + logos = [] + if modele is not None: + for name in _ordered_logos(modele): + logos.append({"dept_key": dept_key, "logo_id": name}) + data["logos"] = logos + return data + + +def _make_depts_data(modele): + data = [] + for dept_id, dept_name in _make_dept_id_name(): + data.append( + _make_dept_data( + dept_id=dept_id, dept_name=dept_name, modele=modele.get(dept_id, None) + ) + ) + return data + + +def _make_data(bonus_sport, modele): + data = { + "bonus_sport_func_name": bonus_sport, + "depts": _make_depts_data(modele=modele), + } + return data + + +class ScoDocConfigurationForm(FlaskForm): + "Panneau de configuration général" + bonus_sport_func_name = SelectField( + label="Fonction de calcul des bonus sport&culture", + choices=[ + (x, x if x else "Aucune") + for x in ScoDocSiteConfig.get_bonus_sport_func_names() + ], + ) + depts = FieldList(FormField(DeptForm)) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # def _set_global_logos_infos(self): + # "specific processing for globals items" + # global_header = self.get_form(logoname="header") + # global_header.description = ( + # "image placée en haut de certains documents documents PDF." + # ) + # global_header.titre = "Logo en-tête" + # global_header.can_delete = False + # global_footer = self.get_form(logoname="footer") + # global_footer.description = ( + # "image placée en pied de page de certains documents documents PDF." + # ) + # global_footer.titre = "Logo pied de page" + # global_footer.can_delete = False + + # def _build_dept(self, dept_id, dept_name, modele): + # dept_key = dept_id or GLOBAL + # data = {"dept_key": dept_key} + # entry = self.depts.append_entry(data) + # entry.form.build(dept_name, modele.get(dept_id, {})) + # self.index[str(dept_key)] = entry.form + + # def build(self, modele): + # "Build the Form hierachy (DeptForm, LogoForm) and add extra data (from modele)" + # # if entries already initialized (POST). keep subforms + # self.index = {} + # # create entries in FieldList (one entry per dept + # for dept_id, dept_name in self.dept_id_name: + # self._build_dept(dept_id=dept_id, dept_name=dept_name, modele=modele) + # self._set_global_logos_infos() + + def get_form(self, dept_key=GLOBAL, logoname=None): + """Retourne un formulaire: + * pour un département (get_form(dept_id)) ou à un logo (get_form(dept_id, logname)) + * propre à un département (get_form(dept_id, logoname) ou global (get_form(logoname)) + retourne None si le formulaire cherché ne peut être trouvé + """ + dept_form = self.index.get(dept_key, None) + if dept_form is None: # département non trouvé + return None + return dept_form.get_form(logoname) + + def select_action(self): + if ( + self.data["bonus_sport_func_name"] + != ScoDocSiteConfig.get_bonus_sport_func_name() + ): + return BonusSportUpdate(self.data) + for dept_entry in self.depts: + dept_form = dept_entry.form + action = dept_form.select_action() + if action: + return action + return None + + +def configuration(): + """Panneau de configuration général""" + auth_name = str(current_user) + if not current_user.is_administrator(): + raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) + form = ScoDocConfigurationForm( + data=_make_data( + bonus_sport=ScoDocSiteConfig.get_bonus_sport_func_name(), + modele=sco_logos.list_logos(), + ) + ) + if form.is_submitted(): + action = form.select_action() + if action: + action.execute() + flash(action.message) + return redirect( + url_for( + "scodoc.configuration", + ) + ) + return render_template( + "configuration.html", + scodoc_dept=None, + title="Configuration ScoDoc", + form=form, + ) diff --git a/app/scodoc/sco_logos.py b/app/scodoc/sco_logos.py index 5808945f0..3f5917bb4 100644 --- a/app/scodoc/sco_logos.py +++ b/app/scodoc/sco_logos.py @@ -46,7 +46,7 @@ from app import Departement, ScoValueError from app.scodoc import sco_utils as scu from PIL import Image as PILImage -GLOBAL = "_GLOBAL" # category for server level logos +GLOBAL = "_" # category for server level logos def find_logo(logoname, dept_id=None, strict=False, prefix=scu.LOGO_FILE_PREFIX): @@ -70,6 +70,18 @@ def find_logo(logoname, dept_id=None, strict=False, prefix=scu.LOGO_FILE_PREFIX) return logo +def delete_logo(name, dept_id=None): + """Delete all files matching logo (dept_id, name) (including all allowed extensions) + Args: + name: The name of the logo + dept_id: the dept_id (if local). Use None to destroy globals logos + """ + logo = find_logo(logoname=name, dept_id=dept_id) + while logo is not None: + os.unlink(logo.select().filepath) + logo = find_logo(logoname=name, dept_id=dept_id) + + def write_logo(stream, name, dept_id=None): """Crée le fichier logo sur le serveur. Le suffixe du fichier (parmi LOGO_IMAGES_ALLOWED_TYPES) est déduit du contenu du stream""" @@ -79,14 +91,15 @@ def write_logo(stream, name, dept_id=None): def list_logos(): """Crée l'inventaire de tous les logos existants. L'inventaire se présente comme un dictionnaire de dictionnaire de Logo: - [GLOBAL][name] pour les logos globaux + [None][name] pour les logos globaux [dept_id][name] pour les logos propres à un département (attention id numérique du dept) + Les départements sans logos sont absents du résultat """ - inventory = {GLOBAL: _list_dept_logos()} # logos globaux (header / footer) + inventory = {None: _list_dept_logos()} # logos globaux (header / footer) for dept in Departement.query.filter_by(visible=True).all(): logos_dept = _list_dept_logos(dept_id=dept.id) if logos_dept: - inventory[dept.acronym] = _list_dept_logos(dept.id) + inventory[dept.id] = _list_dept_logos(dept.id) return inventory @@ -236,7 +249,7 @@ class Logo: """Retourne l'URL permettant d'obtenir l'image du logo""" return url_for( "scodoc.get_logo", - scodoc_dept=self.scodoc_dept_id, + dept_id=self.scodoc_dept_id, name=self.logoname, global_if_not_found=False, ) @@ -245,7 +258,7 @@ class Logo: """Retourne l'URL permettant d'obtenir l'image du logo sous forme de miniature""" return url_for( "scodoc.get_logo_small", - scodoc_dept=self.scodoc_dept_id, + dept_id=self.scodoc_dept_id, name=self.logoname, global_if_not_found=False, ) @@ -254,7 +267,7 @@ class Logo: if self.mm is None: return f'' else: - return f'' + return f'' def guess_image_type(stream) -> str: @@ -268,7 +281,6 @@ def guess_image_type(stream) -> str: def make_logo_local(logoname, dept_name): - breakpoint() depts = Departement.query.filter_by(acronym=dept_name).all() if len(depts) == 0: print(f"no dept {dept_name} found. aborting") diff --git a/app/scodoc/sco_pdf.py b/app/scodoc/sco_pdf.py index 54ed29b26..85ead1674 100755 --- a/app/scodoc/sco_pdf.py +++ b/app/scodoc/sco_pdf.py @@ -60,7 +60,6 @@ from reportlab.lib.pagesizes import letter, A4, landscape from flask import g import app.scodoc.sco_utils as scu -from app.scodoc.sco_logos import find_logo from app.scodoc.sco_utils import CONFIG from app import log from app.scodoc.sco_exceptions import ScoGenError, ScoValueError @@ -193,6 +192,10 @@ class ScolarsPageTemplate(PageTemplate): preferences=None, # dictionnary with preferences, required ): """Initialise our page template.""" + from app.scodoc.sco_logos import ( + find_logo, + ) # defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf + self.preferences = preferences self.pagesbookmarks = pagesbookmarks self.pdfmeta_author = author diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index d83ca14f7..7e08d3558 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -2017,10 +2017,10 @@ class BasePreferences(object): H = [ html_sco_header.sco_header(page_title="Préférences"), "

Préférences globales pour %s

" % scu.ScoURL(), - f"""

modification des logos du département (pour documents pdf)

""" - if current_user.is_administrator() - else "", + # f"""

modification des logos du département (pour documents pdf)

""" + # if current_user.is_administrator() + # else "", """

Ces paramètres s'appliquent par défaut à tous les semestres, sauf si ceux-ci définissent des valeurs spécifiques.

Attention: cliquez sur "Enregistrer les modifications" en bas de page pour appliquer vos changements !

""", diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index cc2ea27af..8dcfc779f 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -867,6 +867,9 @@ div.sco_help { span.wtf-field ul.errors li { color: red; +} +.configuration_logo div.img { + } .configuration_logo div.img-container { width: 256px; @@ -874,6 +877,20 @@ span.wtf-field ul.errors li { .configuration_logo div.img-container img { max-width: 100%; } +.configuration_logo div.img-data { + vertical-align: top; +} +.configuration_logo logo-edit titre { + background-color:lightblue; +} +.configuration_logo logo-edit nom { + float: left; + vertical-align: baseline; +} +.configuration_logo logo-edit description { + float:right; + vertical-align:baseline; +} p.indent { padding-left: 2em; diff --git a/app/static/js/configuration.js b/app/static/js/configuration.js new file mode 100644 index 000000000..b537d572f --- /dev/null +++ b/app/static/js/configuration.js @@ -0,0 +1,6 @@ +function submit_form() { + $("#configuration_form").submit(); +} + +$(function () { +}) \ No newline at end of file diff --git a/app/templates/config_dept.html b/app/templates/config_dept.html new file mode 100644 index 000000000..c602c5f32 --- /dev/null +++ b/app/templates/config_dept.html @@ -0,0 +1,81 @@ +{% macro render_field(field) %} +
+ {{ field.label }} : + {{ field()|safe }} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+
+{% endmacro %} + +{% macro render_logo(logo_form, titre=None) %} + {% if titre %} + + +

{{ titre }}

+
+ + + {% endif %} + + +

{{ logo_form.form.description }} Image actuelle:

+
pas de logo chargé
+ + + {{ logo_form.form.dept_id() }} + {{ logo_form.form.logo_id() }} + Nom: {{ logo_form.form.logo.logoname }}
+{# {{ logo_form.form.description }}
#} + Format: {{ logo_form.logo.suffix }}
+ Taille en px: {{ logo_form.logo.size }}
+ {% if logo_form.logo.mm %} + Taile en mm: {{ logo_form.logo.mm }}
+ {% endif %} + Aspect ratio: {{ logo_form.logo.aspect_ratio }}
+
+ Usage: {{ logo_form.logo.get_usage() }} +
+ {{ render_field(logo_form.upload) }} + {% if logo_form.can_delete %} + {{ render_field(logo_form.do_delete) }} + {% endif %} + + +{% endmacro %} + + +{#{% block app_content %}#} + +{% if scodoc_dept %} +

Logos du département {{ scodoc_dept }}

+{% else %} +

Configuration générale

+{% endif %} + +
+ {{ form.hidden_tag() }} + {% if not scodoc_dept %} +
Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):
+ {{ render_field(form.bonus_sport_func_name)}} + + {% endif %} + + + +
{{ form.submit() }}
+
+{#{% endblock %}#} \ No newline at end of file diff --git a/app/templates/configuration.html b/app/templates/configuration.html index 077c094db..b874d48db 100644 --- a/app/templates/configuration.html +++ b/app/templates/configuration.html @@ -1,10 +1,12 @@ {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} -{% macro render_field(field) %} +{% macro render_field(field, with_label=True) %}
- {{ field.label }} : - {{ field()|safe }} + {% if with_label %} + {{ field.label }} : + {% endif %} + {{ field(**kwargs)|safe }} {% if field.errors %}
    {% for error in field.errors %} @@ -16,66 +18,102 @@
{% endmacro %} -{% macro render_logo(logo_form, titre=None) %} - {% if titre %} - - -

{{ titre }}

- - - {% endif %} - - -

{{ logo_form.form.description }} Image actuelle:

-
pas de logo chargé
- - - Nom: {{ logo_form.form.logo.logoname }}
- {{ logo_form.form.description }}
- Format: {{ logo_form.logo.suffix }}
- Taille en px: {{ logo_form.logo.size }}
- {% if logo_form.logo.mm %} - Taile en mm: {{ logo_form.logo.mm }}
- {% endif %} - Aspect ratio: {{ logo_form.logo.aspect_ratio }}
- Usage: {{ logo_form.logo.get_usage() }} - {{ logo_form.action()|safe }} - {{ render_field(logo_form.upload) }} - {% if logo_form.can_delete %} - {{ render_field(logo_form.do_delete) }} - {% endif %} - - +{% macro render_add_logo(add_logo_form) %} +
+

Ajouter un logo

+ {{ add_logo_form.hidden_tag() }} + {{ render_field(add_logo_form.name) }} + {{ render_field(add_logo_form.upload) }} + {{ render_field(add_logo_form.do_insert, False, onSubmit="submit_form") }} +
{% endmacro %} +{% macro render_logo(dept_form, logo_form) %} +
+ {{ logo_form.hidden_tag() }} + {% if logo_form.titre %} + + +

{{ logo_form.titre }}

+
{{ logo_form.description or "" }}
+ + + {% else %} + + +

Logo personalisé: {{ logo_form.logo_id.data }}

+ {{ logo_form.description or "" }} + + + {% endif %} + + +
+ pas de logo chargé
+ +

{{ logo_form.logo.logoname }} (Format: {{ logo_form.logo.suffix }})

+ Taille: {{ logo_form.logo.size }} px + {% if logo_form.logo.mm %}   /   {{ logo_form.logo.mm }} mm {% endif %}
+ Aspect ratio: {{ logo_form.logo.aspect_ratio }}
+ Usage: {{ logo_form.logo.get_usage() }} + +

Modifier l'image

+ {{ render_field(logo_form.upload, False, onchange="submit_form()") }} + {% if logo_form.can_delete %} +

Supprimer l'image

+ {{ render_field(logo_form.do_delete, False, onSubmit="submit_form()") }} + {% endif %} + + +
+{% endmacro %} + +{% macro render_logos(dept_form) %} + + {% for logo_entry in dept_form.logos.entries %} + {% set logo_form = logo_entry.form %} + {{ render_logo(dept_form, logo_form) }} + {% else %} +

Aucun logo défini en propre à ce département

+ {% endfor %} +
+{% endmacro %} {% block app_content %} -{% if scodoc_dept %} -

Logos du département {{ scodoc_dept }}

-{% else %} -

Configuration générale

-{% endif %} + + -
+ {{ form.hidden_tag() }} - {% if not scodoc_dept %} -
Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):
- {{ render_field(form.bonus_sport_func_name)}} - - {% endif %} - +
{% endblock %} \ No newline at end of file diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 704330250..d875dc8e4 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -46,7 +46,7 @@ from flask_login.utils import login_required, current_user from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed from werkzeug.exceptions import BadRequest, NotFound -from wtforms import SelectField, SubmitField, FormField, validators, Form +from wtforms import SelectField, SubmitField, FormField, validators, Form, FieldList from wtforms.fields import IntegerField from wtforms.fields.simple import BooleanField, StringField, TextAreaField, HiddenField from wtforms.validators import ValidationError, DataRequired, Email, EqualTo @@ -56,7 +56,7 @@ from app.models import Departement, Identite from app.models import FormSemestre, FormsemestreInscription from app.models import ScoDocSiteConfig import sco_version -from app.scodoc import sco_logos +from app.scodoc import sco_logos, sco_config_form from app.scodoc import sco_find_etud from app.scodoc import sco_utils as scu from app.decorators import ( @@ -64,7 +64,9 @@ from app.decorators import ( scodoc7func, scodoc, permission_required_compat_scodoc7, + permission_required, ) +from app.scodoc.sco_config_form import configuration from app.scodoc.sco_exceptions import AccessDenied from app.scodoc.sco_logos import find_logo from app.scodoc.sco_permissions import Permission @@ -180,64 +182,6 @@ def about(scodoc_dept=None): # ---- CONFIGURATION - -class LogoForm(FlaskForm): - action = HiddenField("action") - upload = FileField( - label="Modifier l'image:", - validators=[ - FileAllowed( - scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", - ) - ], - ) - do_delete = SubmitField("Supprimer") - - def set_infos(self, logo, description=None, can_delete=None): - self.logo = logo - self.description = description - self.can_delete = can_delete - - def breakpoint(self, form): - breakpoint() - - def __init__(self, *args, **kwargs): - super(LogoForm, self).__init__(*args, **kwargs) - self.logo = None - self.description = None - self.can_delete = None - - -class ScoDocConfigurationForm(FlaskForm): - "Panneau de configuration général" - - bonus_sport_func_name = SelectField( - label="Fonction de calcul des bonus sport&culture", - choices=[ - (x, x if x else "Aucune") - for x in ScoDocSiteConfig.get_bonus_sport_func_names() - ], - ) - header = FormField(LogoForm) - footer = FormField(LogoForm) - submit = SubmitField("Enregistrer") - - def __init__(self, *args, **kwargs): - super(ScoDocConfigurationForm, self).__init__(*args, **kwargs) - breakpoint() - self.header.form.set_infos( - logo=find_logo("header", dept_id=None).select(), - description="image placée en haut de certains documents documents PDF.", - can_delete=False, - ) - self.footer.form.set_infos( - logo=find_logo("footer", dept_id=None).select(), - description="image placée en pied de page de certains documents documents PDF.", - can_delete=False, - ) - - # Notes pour variables config: (valeurs par défaut des paramètres de département) # Chaines simples # SCOLAR_FONT = "Helvetica" @@ -259,29 +203,13 @@ class ScoDocConfigurationForm(FlaskForm): @bp.route("/ScoDoc/configuration", methods=["GET", "POST"]) @admin_required def configuration(): - "Panneau de configuration général" - form = ScoDocConfigurationForm( - bonus_sport_func_name=ScoDocSiteConfig.get_bonus_sport_func_name(), - ) - if form.validate_on_submit(): - ScoDocSiteConfig.set_bonus_sport_func(form.bonus_sport_func_name.data) - if form.header.data: - sco_logos.write_logo(stream=form.header.data, name="header") - if form.footer.data: - sco_logos.write_logo(stream=form.footer.data, name="footer") - app.clear_scodoc_cache() - flash(f"Configuration enregistrée") - return redirect(url_for("scodoc.index")) - - return render_template( - "configuration.html", - title="Configuration ScoDoc", - form=form, - scodoc_dept=None, - ) + auth_name = str(current_user) + if not current_user.is_administrator(): + raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) + return sco_config_form.configuration() -SMALL_SIZE = (300, 300) +SMALL_SIZE = (200, 200) def _return_logo(name="header", dept_id="", small=False, strict: bool = True): diff --git a/app/views/scolar.py b/app/views/scolar.py index c708495aa..ebfd4d1a2 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -174,7 +174,7 @@ class DeptLogosConfigurationForm(FlaskForm): validators=[ FileAllowed( scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", + f"n'accepte que les fichiers image {','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", ) ], ) @@ -185,7 +185,7 @@ class DeptLogosConfigurationForm(FlaskForm): validators=[ FileAllowed( scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", + f"n'accepte que les fichiers image {','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", ) ], ) @@ -193,36 +193,96 @@ class DeptLogosConfigurationForm(FlaskForm): submit = SubmitField("Enregistrer") -@bp.route("/config_logos", methods=["GET", "POST"]) -@permission_required(Permission.ScoChangePreferences) -def config_logos(scodoc_dept): - "Panneau de configuration général" - form = DeptLogosConfigurationForm() - if form.validate_on_submit(): - if form.logo_header.data: - sco_logos.store_image( - form.logo_header.data, - os.path.join( - scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" - ), - ) - if form.logo_footer.data: - sco_logos.store_image( - form.logo_footer.data, - os.path.join( - scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" - ), - ) - app.clear_scodoc_cache() - flash(f"Logos enregistrés") - return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) +# @bp.route("/config_logos", methods=["GET", "POST"]) +# @permission_required(Permission.ScoChangePreferences) +# def config_logos(scodoc_dept): +# "Panneau de configuration général" +# form = DeptLogosConfigurationForm() +# if form.validate_on_submit(): +# if form.logo_header.data: +# sco_logos.store_image( +# form.logo_header.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" +# ), +# ) +# if form.logo_footer.data: +# sco_logos.store_image( +# form.logo_footer.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" +# ), +# ) +# app.clear_scodoc_cache() +# flash(f"Logos enregistrés") +# return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) +# +# return render_template( +# "configuration.html", +# title="Configuration Logos du département", +# form=form, +# scodoc_dept=scodoc_dept, +# ) +# +# +# class DeptLogosConfigurationForm(FlaskForm): +# "Panneau de configuration logos dept" +# +# logo_header = FileField( +# label="Modifier l'image:", +# description="logo placé en haut des documents PDF", +# validators=[ +# FileAllowed( +# scu.LOGOS_IMAGES_ALLOWED_TYPES, +# f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", +# ) +# ], +# ) +# +# logo_footer = FileField( +# label="Modifier l'image:", +# description="logo placé en pied des documents PDF", +# validators=[ +# FileAllowed( +# scu.LOGOS_IMAGES_ALLOWED_TYPES, +# f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", +# ) +# ], +# ) +# +# submit = SubmitField("Enregistrer") - return render_template( - "configuration.html", - title="Configuration Logos du département", - form=form, - scodoc_dept=scodoc_dept, - ) + +# @bp.route("/config_logos", methods=["GET", "POST"]) +# @permission_required(Permission.ScoChangePreferences) +# def config_logos(scodoc_dept): +# "Panneau de configuration général" +# form = DeptLogosConfigurationForm() +# if form.validate_on_submit(): +# if form.logo_header.data: +# sco_logos.store_image( +# form.logo_header.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" +# ), +# ) +# if form.logo_footer.data: +# sco_logos.store_image( +# form.logo_footer.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" +# ), +# ) +# app.clear_scodoc_cache() +# flash(f"Logos enregistrés") +# return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) +# +# return render_template( +# "configuration.html", +# title="Configuration Logos du département", +# form=form, +# scodoc_dept=scodoc_dept, +# ) # -------------------------------------------------------------------- diff --git a/tests/ressources/test_logos/logo_A.jpg b/tests/ressources/test_logos/logo_A.jpg index 7f107a05b..7770372c7 100644 Binary files a/tests/ressources/test_logos/logo_A.jpg and b/tests/ressources/test_logos/logo_A.jpg differ diff --git a/tests/ressources/test_logos/logo_A1.jpg b/tests/ressources/test_logos/logo_A1.jpg new file mode 100644 index 000000000..e645ef63d Binary files /dev/null and b/tests/ressources/test_logos/logo_A1.jpg differ diff --git a/tests/ressources/test_logos/logo_C.jpg b/tests/ressources/test_logos/logo_C.jpg index bdca2b736..108afc713 100644 Binary files a/tests/ressources/test_logos/logo_C.jpg and b/tests/ressources/test_logos/logo_C.jpg differ diff --git a/tests/ressources/test_logos/logo_D.png b/tests/ressources/test_logos/logo_D.png index 8239e535c..cc7276cda 100644 Binary files a/tests/ressources/test_logos/logo_D.png and b/tests/ressources/test_logos/logo_D.png differ diff --git a/tests/ressources/test_logos/logo_E.jpg b/tests/ressources/test_logos/logo_E.jpg index e97b78d07..e51e7b46f 100644 Binary files a/tests/ressources/test_logos/logo_E.jpg and b/tests/ressources/test_logos/logo_E.jpg differ diff --git a/tests/ressources/test_logos/logo_F.jpeg b/tests/ressources/test_logos/logo_F.jpeg index c3750e29f..20728130a 100644 Binary files a/tests/ressources/test_logos/logo_F.jpeg and b/tests/ressources/test_logos/logo_F.jpeg differ diff --git a/tests/ressources/test_logos/logos_1/logo_A.jpg b/tests/ressources/test_logos/logos_1/logo_A.jpg index 96cfabd5e..81179f9c3 100644 Binary files a/tests/ressources/test_logos/logos_1/logo_A.jpg and b/tests/ressources/test_logos/logos_1/logo_A.jpg differ diff --git a/tests/ressources/test_logos/logos_1/logo_B.jpg b/tests/ressources/test_logos/logos_1/logo_B.jpg index 2ebb82823..2428dc452 100644 Binary files a/tests/ressources/test_logos/logos_1/logo_B.jpg and b/tests/ressources/test_logos/logos_1/logo_B.jpg differ diff --git a/tests/ressources/test_logos/logos_2/logo_A.jpg b/tests/ressources/test_logos/logos_2/logo_A.jpg index 8c1c4efcb..28d2a9c14 100644 Binary files a/tests/ressources/test_logos/logos_2/logo_A.jpg and b/tests/ressources/test_logos/logos_2/logo_A.jpg differ diff --git a/tests/ressources/test_logos/logos_2/logo_A1.jpg b/tests/ressources/test_logos/logos_2/logo_A1.jpg new file mode 100644 index 000000000..5bdf7500c Binary files /dev/null and b/tests/ressources/test_logos/logos_2/logo_A1.jpg differ diff --git a/tests/unit/test_logos.py b/tests/unit/test_logos.py index 1ed62a493..6af366489 100644 --- a/tests/unit/test_logos.py +++ b/tests/unit/test_logos.py @@ -7,7 +7,6 @@ Utiliser comme: pytest tests/unit/test_logos.py """ -from io import BytesIO from pathlib import Path from shutil import copytree, copy, rmtree @@ -18,7 +17,14 @@ import app from app import db from app.models import Departement import app.scodoc.sco_utils as scu -from app.scodoc.sco_logos import find_logo, Logo, list_logos +from app.scodoc.sco_logos import ( + find_logo, + Logo, + list_logos, + GLOBAL, + write_logo, + delete_logo, +) RESOURCES_DIR = "/opt/scodoc/tests/ressources/test_logos" @@ -30,12 +36,15 @@ def create_dept(test_client): """ dept1 = Departement(acronym="RT") dept2 = Departement(acronym="INFO") + dept3 = Departement(acronym="GEA") db.session.add(dept1) db.session.add(dept2) + db.session.add(dept3) db.session.commit() - yield dept1, dept2 + yield dept1, dept2, dept3 db.session.delete(dept1) db.session.delete(dept2) + db.session.delete(dept3) db.session.commit() @@ -52,9 +61,10 @@ def create_logos(create_dept): +-- logos_{d2} --+-- logo_A.jpg """ - dept1, dept2 = create_dept + dept1, dept2, dept3 = create_dept d1 = dept1.id d2 = dept2.id + d3 = dept3.id FILE_LIST = ["logo_A.jpg", "logo_C.jpg", "logo_D.png", "logo_E.jpg", "logo_F.jpeg"] for fn in FILE_LIST: from_path = Path(RESOURCES_DIR).joinpath(fn) @@ -83,33 +93,33 @@ def test_select_global_only(create_logos): def test_select_local_only(create_dept, create_logos): - dept1, dept2 = create_dept + dept1, dept2, dept3 = create_dept B_logo = app.scodoc.sco_logos.find_logo(logoname="B", dept_id=dept1.id) assert B_logo.filepath == f"{scu.SCODOC_LOGOS_DIR}/logos_{dept1.id}/logo_B.jpg" def test_select_local_override_global(create_dept, create_logos): - dept1, dept2 = create_dept + dept1, dept2, dept3 = create_dept A1_logo = app.scodoc.sco_logos.find_logo(logoname="A", dept_id=dept1.id) assert A1_logo.filepath == f"{scu.SCODOC_LOGOS_DIR}/logos_{dept1.id}/logo_A.jpg" def test_select_global_with_strict(create_dept, create_logos): - dept1, dept2 = create_dept + dept1, dept2, dept3 = create_dept A_logo = app.scodoc.sco_logos.find_logo(logoname="A", dept_id=dept1.id, strict=True) assert A_logo.filepath == f"{scu.SCODOC_LOGOS_DIR}/logos_{dept1.id}/logo_A.jpg" def test_looks_for_non_existant_should_give_none(create_dept, create_logos): # search for a local non-existant logo returns None - dept1, dept2 = create_dept + dept1, dept2, dept3 = create_dept no_logo = app.scodoc.sco_logos.find_logo(logoname="Z", dept_id=dept1.id) assert no_logo is None def test_looks_localy_for_a_global_should_give_none(create_dept, create_logos): # search for a local non-existant logo returns None - dept1, dept2 = create_dept + dept1, dept2, dept3 = create_dept no_logo = app.scodoc.sco_logos.find_logo( logoname="C", dept_id=dept1.id, strict=True ) @@ -123,8 +133,8 @@ def test_get_jpg_data(create_dept, create_logos): assert logo.logoname == "A" assert logo.suffix == "jpg" assert logo.filename == "A.jpg" - assert logo.size == (1200, 600) - assert logo.mm == approx((40, 30), 0.1) + assert logo.size == (140, 121) + assert logo.mm == approx((5.74, 4.96), 0.1) def test_get_png_without_data(create_dept, create_logos): @@ -139,10 +149,59 @@ def test_get_png_without_data(create_dept, create_logos): assert logo.mm is None -def test_create_globale_jpg_logo(create_dept, create_logos): +def test_delete_unique_global_jpg_logo(create_dept, create_logos): + from_path = Path(RESOURCES_DIR).joinpath("logo_A.jpg") + to_path = Path(scu.SCODOC_LOGOS_DIR).joinpath("logo_W.jpg") + copy(from_path.absolute(), to_path.absolute()) + assert to_path.exists() + delete_logo(name="W") + assert not to_path.exists() + + +def test_delete_unique_local_jpg_logo(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + from_path = Path(RESOURCES_DIR).joinpath("logo_A.jpg") + to_path = Path(scu.SCODOC_LOGOS_DIR).joinpath(f"logos_{dept1.id}", "logo_W.jpg") + copy(from_path.absolute(), to_path.absolute()) + assert to_path.exists() + delete_logo(name="W", dept_id=dept1.id) + assert not to_path.exists() + + +def test_delete_multiple_local_jpg_logo(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + from_path_A = Path(RESOURCES_DIR).joinpath("logo_A.jpg") + to_path_A = Path(scu.SCODOC_LOGOS_DIR).joinpath(f"logos_{dept1.id}", "logo_V.jpg") + from_path_B = Path(RESOURCES_DIR).joinpath("logo_D.png") + to_path_B = Path(scu.SCODOC_LOGOS_DIR).joinpath(f"logos_{dept1.id}", "logo_V.png") + copy(from_path_A.absolute(), to_path_A.absolute()) + copy(from_path_B.absolute(), to_path_B.absolute()) + assert to_path_A.exists() + assert to_path_B.exists() + delete_logo(name="V", dept_id=dept1.id) + assert not to_path_A.exists() + assert not to_path_B.exists() + + +def test_create_global_jpg_logo(create_dept, create_logos): path = Path(f"{RESOURCES_DIR}/logo_C.jpg") - logo = Logo("X") # create global logo stream = path.open("rb") + logo_path = Path(scu.SCODOC_LOGOS_DIR).joinpath("logo_X.jpg") + assert not logo_path.exists() + write_logo(stream, name="X") # create global logo + assert logo_path.exists() + logo_path.unlink(missing_ok=True) + + +def test_create_locale_jpg_logo(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + path = Path(f"{RESOURCES_DIR}/logo_C.jpg") + stream = path.open("rb") + logo_path = Path(scu.SCODOC_LOGOS_DIR).joinpath(f"logos_{dept1.id}", "logo_Y.jpg") + assert not logo_path.exists() + write_logo(stream, name="Y", dept_id=dept1.id) # create global logo + assert logo_path.exists() + logo_path.unlink(missing_ok=True) def test_create_jpg_instead_of_png_logo(create_dept, create_logos): @@ -169,15 +228,17 @@ def test_create_jpg_instead_of_png_logo(create_dept, create_logos): def test_list_logo(create_dept, create_logos): # test only existence of copied logos. We assumes that they are OK - dept1, dept2 = create_dept + dept1, dept2, dept3 = create_dept logos = list_logos() - assert logos.keys() == {"_GLOBAL", "RT", "INFO"} + assert set(logos.keys()) == {dept1.id, dept2.id, None} assert {"A", "C", "D", "E", "F", "header", "footer"}.issubset( - set(logos["_GLOBAL"].keys()) + set(logos[None].keys()) ) - rt = logos.get("RT", None) + rt = logos.get(dept1.id, None) assert rt is not None assert {"A", "B"}.issubset(set(rt.keys())) - info = logos.get("INFO", None) + info = logos.get(dept2.id, None) assert info is not None assert {"A"}.issubset(set(rt.keys())) + gea = logos.get(dept3.id, None) + assert gea is None