Skip to content

Commit d856abb

Browse files
committed
✨(back) allow theme customnization using a configuration file
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.
1 parent 25abd96 commit d856abb

File tree

8 files changed

+401
-19
lines changed

8 files changed

+401
-19
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to
1010

1111
## Added
1212

13+
- ✨(back) allow theme customnization using a configuration file #948
1314
- ✨ Add a custom callout block to the editor #892
1415
- 🚩(frontend) version MIT only #911
1516
- ✨(backend) integrate maleware_detection from django-lasuite #936

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
8787
# Copy entrypoint
8888
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
8989

90+
# Copy configuration
91+
VOLUME [ "/configuration" ]
92+
COPY ./configuration /configuration
93+
9094
# Give the "root" group the same permissions as the "root" user on /etc/passwd
9195
# to allow a user belonging to the root group to add new users; typically the
9296
# docker user (see entrypoint).

configuration/theme/default.json

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
{
2+
"footer": {
3+
"default": {
4+
"externalLinks": [
5+
{
6+
"label": "Github",
7+
"href": "https://github.com/suitenumerique/docs/"
8+
},
9+
{
10+
"label": "DINUM",
11+
"href": "https://www.numerique.gouv.fr/dinum/"
12+
},
13+
{
14+
"label": "ZenDiS",
15+
"href": "https://zendis.de/"
16+
},
17+
{
18+
"label": "BlockNote.js",
19+
"href": "https://www.blocknotejs.org/"
20+
}
21+
],
22+
"bottomInformation": {
23+
"label": "Unless otherwise stated, all content on this site is under",
24+
"link": {
25+
"label": "licence etalab-2.0",
26+
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
27+
}
28+
}
29+
},
30+
"en": {
31+
"legalLinks": [
32+
{
33+
"label": "Legal Notice",
34+
"href": "#"
35+
},
36+
{
37+
"label": "Personal data and cookies",
38+
"href": "#"
39+
},
40+
{
41+
"label": "Accessibility",
42+
"href": "#"
43+
}
44+
],
45+
"bottomInformation": {
46+
"label": "Unless otherwise stated, all content on this site is under",
47+
"link": {
48+
"label": "licence MIT",
49+
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
50+
}
51+
}
52+
},
53+
"fr": {
54+
"legalLinks": [
55+
{
56+
"label": "Mentions légales",
57+
"href": "#"
58+
},
59+
{
60+
"label": "Données personnelles et cookies",
61+
"href": "#"
62+
},
63+
{
64+
"label": "Accessibilité",
65+
"href": "#"
66+
}
67+
],
68+
"bottomInformation": {
69+
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
70+
"link": {
71+
"label": "licence MIT",
72+
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
73+
}
74+
}
75+
},
76+
"de": {
77+
"legalLinks": [
78+
{
79+
"label": "Impressum",
80+
"href": "#"
81+
},
82+
{
83+
"label": "Personenbezogene Daten und Cookies",
84+
"href": "#"
85+
},
86+
{
87+
"label": "Barrierefreiheit",
88+
"href": "#"
89+
}
90+
],
91+
"bottomInformation": {
92+
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
93+
"link": {
94+
"label": "licence MIT",
95+
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
96+
}
97+
}
98+
},
99+
"nl": {
100+
"legalLinks": [
101+
{
102+
"label": "Wettelijke bepalingen",
103+
"href": "#"
104+
},
105+
{
106+
"label": "Persoonlijke gegevens en cookies",
107+
"href": "#"
108+
},
109+
{
110+
"label": "Toegankelijkheid",
111+
"href": "#"
112+
}
113+
],
114+
"bottomInformation": {
115+
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
116+
"link": {
117+
"label": "licence MIT",
118+
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
119+
}
120+
}
121+
}
122+
}
123+
}

docs/env.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,8 @@ These are the environmental variables you can set for the impress-backend contai
9898
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
9999
| REDIS_URL | cache url | redis://redis:6379/1 |
100100
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
101+
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
101102
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
102-
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
103+
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
104+
| 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 |
105+
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |

src/backend/core/api/viewsets.py

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""API endpoints"""
22
# pylint: disable=too-many-lines
33

4+
import json
45
import logging
56
import uuid
67
from urllib.parse import unquote, urlparse
@@ -9,17 +10,16 @@
910
from django.contrib.postgres.aggregates import ArrayAgg
1011
from django.contrib.postgres.fields import ArrayField
1112
from django.contrib.postgres.search import TrigramSimilarity
13+
from django.core.cache import cache
1214
from django.core.exceptions import ValidationError
1315
from django.core.files.storage import default_storage
1416
from django.db import connection, transaction
1517
from django.db import models as db
1618
from django.db.models.expressions import RawSQL
1719
from django.db.models.functions import Left, Length
1820
from django.http import Http404, StreamingHttpResponse
19-
from django.utils.decorators import method_decorator
20-
from django.utils.text import capfirst
21+
from django.utils.text import capfirst, slugify
2122
from django.utils.translation import gettext_lazy as _
22-
from django.views.decorators.cache import cache_page
2323

2424
import requests
2525
import rest_framework as drf
@@ -1747,23 +1747,41 @@ def get(self, request):
17471747
if hasattr(settings, setting):
17481748
dict_settings[setting] = getattr(settings, setting)
17491749

1750+
dict_settings["theme_customization"] = self._load_theme_customization()
1751+
17501752
return drf.response.Response(dict_settings)
17511753

1754+
def _load_theme_customization(self):
1755+
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
1756+
return {}
17521757

1753-
class FooterView(drf.views.APIView):
1754-
"""API ViewSet for sharing the footer JSON."""
1758+
cache_key = (
1759+
f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}"
1760+
)
1761+
theme_customization = cache.get(cache_key, {})
1762+
if theme_customization:
1763+
return theme_customization
17551764

1756-
permission_classes = [AllowAny]
1765+
try:
1766+
with open(
1767+
settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8"
1768+
) as f:
1769+
theme_customization = json.load(f)
1770+
except FileNotFoundError:
1771+
logger.error(
1772+
"Configuration file not found: %s",
1773+
settings.THEME_CUSTOMIZATION_FILE_PATH,
1774+
)
1775+
except json.JSONDecodeError:
1776+
logger.error(
1777+
"Configuration file is not a valid JSON: %s",
1778+
settings.THEME_CUSTOMIZATION_FILE_PATH,
1779+
)
1780+
else:
1781+
cache.set(
1782+
cache_key,
1783+
theme_customization,
1784+
settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT,
1785+
)
17571786

1758-
@method_decorator(cache_page(settings.FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT))
1759-
def get(self, request):
1760-
"""
1761-
GET /api/v1.0/footer/
1762-
Return the footer JSON.
1763-
"""
1764-
json_footer = (
1765-
get_footer_json(settings.FRONTEND_URL_JSON_FOOTER)
1766-
if settings.FRONTEND_URL_JSON_FOOTER
1767-
else {}
1768-
)
1769-
return drf.response.Response(json_footer)
1787+
return theme_customization

src/backend/core/tests/test_api_config.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Test config API endpoints in the Impress core app.
33
"""
44

5+
import json
6+
57
from django.test import override_settings
68

79
import pytest
@@ -24,6 +26,7 @@
2426
MEDIA_BASE_URL="http://testserver/",
2527
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
2628
SENTRY_DSN="https://sentry.test/123",
29+
THEME_CUSTOMIZATION_FILE_PATH="",
2730
)
2831
@pytest.mark.parametrize("is_authenticated", [False, True])
2932
def test_api_config(is_authenticated):
@@ -56,4 +59,98 @@ def test_api_config(is_authenticated):
5659
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
5760
"SENTRY_DSN": "https://sentry.test/123",
5861
"AI_FEATURE_ENABLED": False,
62+
"theme_customization": {},
63+
}
64+
65+
66+
@override_settings(
67+
THEME_CUSTOMIZATION_FILE_PATH="/not/existing/file.json",
68+
)
69+
@pytest.mark.parametrize("is_authenticated", [False, True])
70+
def test_api_config_with_invalid_theme_customization_file(is_authenticated):
71+
"""Anonymous users should be allowed to get the configuration."""
72+
client = APIClient()
73+
74+
if is_authenticated:
75+
user = factories.UserFactory()
76+
client.force_login(user)
77+
78+
response = client.get("/api/v1.0/config/")
79+
assert response.status_code == HTTP_200_OK
80+
content = response.json()
81+
assert content["theme_customization"] == {}
82+
83+
84+
@override_settings(
85+
THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/invalid.json",
86+
)
87+
@pytest.mark.parametrize("is_authenticated", [False, True])
88+
def test_api_config_with_invalid_json_theme_customization_file(is_authenticated, fs):
89+
"""Anonymous users should be allowed to get the configuration."""
90+
fs.create_file(
91+
"/configuration/theme/invalid.json",
92+
contents="invalid json",
93+
)
94+
client = APIClient()
95+
96+
if is_authenticated:
97+
user = factories.UserFactory()
98+
client.force_login(user)
99+
100+
response = client.get("/api/v1.0/config/")
101+
assert response.status_code == HTTP_200_OK
102+
content = response.json()
103+
assert content["theme_customization"] == {}
104+
105+
106+
@override_settings(
107+
THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/default.json",
108+
)
109+
@pytest.mark.parametrize("is_authenticated", [False, True])
110+
def test_api_config_with_theme_customization(is_authenticated, fs):
111+
"""Anonymous users should be allowed to get the configuration."""
112+
fs.create_file(
113+
"/configuration/theme/default.json",
114+
contents=json.dumps(
115+
{
116+
"colors": {
117+
"primary": "#000000",
118+
"secondary": "#000000",
119+
},
120+
}
121+
),
122+
)
123+
client = APIClient()
124+
125+
if is_authenticated:
126+
user = factories.UserFactory()
127+
client.force_login(user)
128+
129+
response = client.get("/api/v1.0/config/")
130+
assert response.status_code == HTTP_200_OK
131+
content = response.json()
132+
assert content["theme_customization"] == {
133+
"colors": {
134+
"primary": "#000000",
135+
"secondary": "#000000",
136+
},
59137
}
138+
139+
140+
@pytest.mark.parametrize("is_authenticated", [False, True])
141+
def test_api_config_with_original_theme_customization(is_authenticated, settings):
142+
"""Anonymous users should be allowed to get the configuration."""
143+
client = APIClient()
144+
145+
if is_authenticated:
146+
user = factories.UserFactory()
147+
client.force_login(user)
148+
149+
response = client.get("/api/v1.0/config/")
150+
assert response.status_code == HTTP_200_OK
151+
content = response.json()
152+
153+
with open(settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8") as f:
154+
theme_customization = json.load(f)
155+
156+
assert content["theme_customization"] == theme_customization

0 commit comments

Comments
 (0)