From d856abb5d8042fdca674793eae00e577de117dd6 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 7 May 2025 11:50:04 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(back)=20allow=20theme=20customnizatio?= =?UTF-8?q?n=20using=20a=20configuration=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to customize the theme by using a configuration file. This configuration file path can be defined using the settings THEME_CUSTOMIZATION_FILE_PATH. If this file does not exists or is an invalid json, an empty json object will be added in the config endpoint. --- CHANGELOG.md | 1 + Dockerfile | 4 + configuration/theme/default.json | 123 +++++++++++++++++ docs/env.md | 5 +- src/backend/core/api/viewsets.py | 56 +++++--- src/backend/core/tests/test_api_config.py | 97 ++++++++++++++ .../impress/configuration/theme/default.json | 124 ++++++++++++++++++ src/backend/impress/settings.py | 12 ++ 8 files changed, 402 insertions(+), 20 deletions(-) create mode 100644 configuration/theme/default.json create mode 100644 src/backend/impress/configuration/theme/default.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e5596f63..33139d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ## Added +- ✨(back) allow theme customnization using a configuration file #948 - ✨ Add a custom callout block to the editor #892 - 🚩(frontend) version MIT only #911 - ✨(backend) integrate maleware_detection from django-lasuite #936 diff --git a/Dockerfile b/Dockerfile index 23f26b70..89ee616c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,6 +87,10 @@ RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types # Copy entrypoint COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint +# Copy configuration +VOLUME [ "/configuration" ] +COPY ./configuration /configuration + # Give the "root" group the same permissions as the "root" user on /etc/passwd # to allow a user belonging to the root group to add new users; typically the # docker user (see entrypoint). diff --git a/configuration/theme/default.json b/configuration/theme/default.json new file mode 100644 index 00000000..aeefb536 --- /dev/null +++ b/configuration/theme/default.json @@ -0,0 +1,123 @@ +{ + "footer": { + "default": { + "externalLinks": [ + { + "label": "Github", + "href": "https://github.com/suitenumerique/docs/" + }, + { + "label": "DINUM", + "href": "https://www.numerique.gouv.fr/dinum/" + }, + { + "label": "ZenDiS", + "href": "https://zendis.de/" + }, + { + "label": "BlockNote.js", + "href": "https://www.blocknotejs.org/" + } + ], + "bottomInformation": { + "label": "Unless otherwise stated, all content on this site is under", + "link": { + "label": "licence etalab-2.0", + "href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md" + } + } + }, + "en": { + "legalLinks": [ + { + "label": "Legal Notice", + "href": "#" + }, + { + "label": "Personal data and cookies", + "href": "#" + }, + { + "label": "Accessibility", + "href": "#" + } + ], + "bottomInformation": { + "label": "Unless otherwise stated, all content on this site is under", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "fr": { + "legalLinks": [ + { + "label": "Mentions légales", + "href": "#" + }, + { + "label": "Données personnelles et cookies", + "href": "#" + }, + { + "label": "Accessibilité", + "href": "#" + } + ], + "bottomInformation": { + "label": "Sauf mention contraire, tout le contenu de ce site est sous", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "de": { + "legalLinks": [ + { + "label": "Impressum", + "href": "#" + }, + { + "label": "Personenbezogene Daten und Cookies", + "href": "#" + }, + { + "label": "Barrierefreiheit", + "href": "#" + } + ], + "bottomInformation": { + "label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "nl": { + "legalLinks": [ + { + "label": "Wettelijke bepalingen", + "href": "#" + }, + { + "label": "Persoonlijke gegevens en cookies", + "href": "#" + }, + { + "label": "Toegankelijkheid", + "href": "#" + } + ], + "bottomInformation": { + "label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + } + } +} diff --git a/docs/env.md b/docs/env.md index d983e44b..28c2ffe0 100644 --- a/docs/env.md +++ b/docs/env.md @@ -98,5 +98,8 @@ These are the environmental variables you can set for the impress-backend contai | DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | | REDIS_URL | cache url | redis://redis:6379/1 | | CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 | +| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | | MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | -| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | \ No newline at end of file +| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | +| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | +| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 7f2bb7bb..9b4115d0 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,6 +1,7 @@ """API endpoints""" # pylint: disable=too-many-lines +import json import logging import uuid from urllib.parse import unquote, urlparse @@ -9,6 +10,7 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.search import TrigramSimilarity +from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.db import connection, transaction @@ -16,10 +18,8 @@ from django.db import models as db from django.db.models.expressions import RawSQL from django.db.models.functions import Left, Length from django.http import Http404, StreamingHttpResponse -from django.utils.decorators import method_decorator -from django.utils.text import capfirst +from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ -from django.views.decorators.cache import cache_page import requests import rest_framework as drf @@ -1747,23 +1747,41 @@ class ConfigView(drf.views.APIView): if hasattr(settings, setting): dict_settings[setting] = getattr(settings, setting) + dict_settings["theme_customization"] = self._load_theme_customization() + return drf.response.Response(dict_settings) + def _load_theme_customization(self): + if not settings.THEME_CUSTOMIZATION_FILE_PATH: + return {} -class FooterView(drf.views.APIView): - """API ViewSet for sharing the footer JSON.""" - - permission_classes = [AllowAny] - - @method_decorator(cache_page(settings.FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT)) - def get(self, request): - """ - GET /api/v1.0/footer/ - Return the footer JSON. - """ - json_footer = ( - get_footer_json(settings.FRONTEND_URL_JSON_FOOTER) - if settings.FRONTEND_URL_JSON_FOOTER - else {} + cache_key = ( + f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}" ) - return drf.response.Response(json_footer) + theme_customization = cache.get(cache_key, {}) + if theme_customization: + return theme_customization + + try: + with open( + settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8" + ) as f: + theme_customization = json.load(f) + except FileNotFoundError: + logger.error( + "Configuration file not found: %s", + settings.THEME_CUSTOMIZATION_FILE_PATH, + ) + except json.JSONDecodeError: + logger.error( + "Configuration file is not a valid JSON: %s", + settings.THEME_CUSTOMIZATION_FILE_PATH, + ) + else: + cache.set( + cache_key, + theme_customization, + settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT, + ) + + return theme_customization diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 508ef2c7..e67b70c3 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -2,6 +2,8 @@ Test config API endpoints in the Impress core app. """ +import json + from django.test import override_settings import pytest @@ -24,6 +26,7 @@ pytestmark = pytest.mark.django_db MEDIA_BASE_URL="http://testserver/", POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"}, SENTRY_DSN="https://sentry.test/123", + THEME_CUSTOMIZATION_FILE_PATH="", ) @pytest.mark.parametrize("is_authenticated", [False, True]) def test_api_config(is_authenticated): @@ -56,4 +59,98 @@ def test_api_config(is_authenticated): "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, "SENTRY_DSN": "https://sentry.test/123", "AI_FEATURE_ENABLED": False, + "theme_customization": {}, } + + +@override_settings( + THEME_CUSTOMIZATION_FILE_PATH="/not/existing/file.json", +) +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config_with_invalid_theme_customization_file(is_authenticated): + """Anonymous users should be allowed to get the configuration.""" + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + content = response.json() + assert content["theme_customization"] == {} + + +@override_settings( + THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/invalid.json", +) +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config_with_invalid_json_theme_customization_file(is_authenticated, fs): + """Anonymous users should be allowed to get the configuration.""" + fs.create_file( + "/configuration/theme/invalid.json", + contents="invalid json", + ) + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + content = response.json() + assert content["theme_customization"] == {} + + +@override_settings( + THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/default.json", +) +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config_with_theme_customization(is_authenticated, fs): + """Anonymous users should be allowed to get the configuration.""" + fs.create_file( + "/configuration/theme/default.json", + contents=json.dumps( + { + "colors": { + "primary": "#000000", + "secondary": "#000000", + }, + } + ), + ) + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + content = response.json() + assert content["theme_customization"] == { + "colors": { + "primary": "#000000", + "secondary": "#000000", + }, + } + + +@pytest.mark.parametrize("is_authenticated", [False, True]) +def test_api_config_with_original_theme_customization(is_authenticated, settings): + """Anonymous users should be allowed to get the configuration.""" + client = APIClient() + + if is_authenticated: + user = factories.UserFactory() + client.force_login(user) + + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + content = response.json() + + with open(settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8") as f: + theme_customization = json.load(f) + + assert content["theme_customization"] == theme_customization diff --git a/src/backend/impress/configuration/theme/default.json b/src/backend/impress/configuration/theme/default.json new file mode 100644 index 00000000..c34df5c0 --- /dev/null +++ b/src/backend/impress/configuration/theme/default.json @@ -0,0 +1,124 @@ +{ + "footer": { + "default": { + "externalLinks": [ + { + "label": "Github", + "href": "https://github.com/suitenumerique/docs/" + }, + { + "label": "DINUM", + "href": "https://www.numerique.gouv.fr/dinum/" + }, + { + "label": "ZenDiS", + "href": "https://zendis.de/" + }, + { + "label": "BlockNote.js", + "href": "https://www.blocknotejs.org/" + } + ], + "bottomInformation": { + "label": "Unless otherwise stated, all content on this site is under", + "link": { + "label": "licence etalab-2.0", + "href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md" + } + } + }, + "en": { + "legalLinks": [ + { + "label": "Legal Notice", + "href": "#" + }, + { + "label": "Personal data and cookies", + "href": "#" + }, + { + "label": "Accessibility", + "href": "#" + } + ], + "bottomInformation": { + "label": "Unless otherwise stated, all content on this site is under", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "fr": { + "legalLinks": [ + { + "label": "Mentions légales", + "href": "#" + }, + { + "label": "Données personnelles et cookies", + "href": "#" + }, + { + "label": "Accessibilité", + "href": "#" + } + ], + "bottomInformation": { + "label": "Sauf mention contraire, tout le contenu de ce site est sous", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "de": { + "legalLinks": [ + { + "label": "Impressum", + "href": "#" + }, + { + "label": "Personenbezogene Daten und Cookies", + "href": "#" + }, + { + "label": "Barrierefreiheit", + "href": "#" + } + ], + "bottomInformation": { + "label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "nl": { + "legalLinks": [ + { + "label": "Wettelijke bepalingen", + "href": "#" + }, + { + "label": "Persoonlijke gegevens en cookies", + "href": "#" + }, + { + "label": "Toegankelijkheid", + "href": "#" + } + ], + "bottomInformation": { + "label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + } + } + } + \ No newline at end of file diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index c831c3ba..120f0445 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -440,6 +440,18 @@ class Base(Configuration): None, environ_name="FRONTEND_CSS_URL", environ_prefix=None ) + THEME_CUSTOMIZATION_FILE_PATH = values.Value( + os.path.join(BASE_DIR, "impress/configuration/theme/default.json"), + environ_name="THEME_CUSTOMIZATION_FILE_PATH", + environ_prefix=None, + ) + + THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.Value( + 60 * 60 * 24, + environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT", + environ_prefix=None, + ) + # Posthog POSTHOG_KEY = values.DictValue( None, environ_name="POSTHOG_KEY", environ_prefix=None