Skip to content

Commit 2067013

Browse files
authored
[fix] Fixed compatibility of custom theme assets with collectstatic
Fixed an issue where custom CSS or JavaScript files referenced in ``OPENWISP_ADMIN_THEME_LINKS`` using ``/static/`` paths were not recognized by Django and therefore not collected or served correctly. Custom theme assets are now mounted in the dashboard container and integrated into Django’s staticfiles pipeline so they are processed by ``collectstatic`` and served correctly via Nginx
1 parent b8e9836 commit 2067013

File tree

8 files changed

+216
-36
lines changed

8 files changed

+216
-36
lines changed

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,19 @@ jobs:
6464
if: ${{ !cancelled() && steps.set_git_branch.conclusion == 'success' }}
6565
# Do not remove the blank lines from the input.
6666
run: |
67-
printf "latest\n" | \
67+
printf "edge\n" | \
6868
GIT_BRANCH="${GIT_BRANCH}" SKIP_PULL=true sudo -E ./deploy/auto-install.sh --upgrade \
6969
|| (cat /opt/openwisp/autoinstall.log && exit 1)
7070
71+
# The test suite needs to create files during execution. Because the deploy
72+
# script runs with `sudo`, the installation directory ends up owned by root,
73+
# making it inaccessible to the unprivileged CI user. This step fixes the
74+
# ownership so the test suite can write the required files.
75+
- name: Fix permissions for CI user
76+
if: ${{ !cancelled() && steps.auto_install_upgrade.conclusion == 'success' }}
77+
run: |
78+
sudo chown -R $USER:$USER /opt/openwisp
79+
7180
- name: Test
7281
if: ${{ !cancelled() && steps.auto_install_upgrade.conclusion == 'success' }}
7382
uses: openwisp/openwisp-utils/.github/actions/retry-command@master

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# the heading "Makefile Options".
33

44
# The .env file can override ?= variables in the Makefile (e.g. OPENWISP_VERSION, IMAGE_OWNER)
5-
include .env
5+
include .env
66

77
# RELEASE_VERSION: version string used when tagging a new release.
88
RELEASE_VERSION = 25.10.0

docker-compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ services:
2929
- openwisp_ssh:/home/openwisp/.ssh
3030
- influxdb_data:/var/lib/influxdb
3131
- ./customization/configuration/django/:/opt/openwisp/openwisp/configuration:ro
32+
- ./customization/theme:/opt/openwisp/static_custom:ro
3233
depends_on:
3334
- postgres
3435
- redis
@@ -128,7 +129,7 @@ services:
128129
- openwisp_media:/opt/openwisp/public/media:ro
129130
- openwisp_private_storage:/opt/openwisp/public/private:ro
130131
- openwisp_certs:/etc/letsencrypt
131-
- ./customization/theme:/opt/openwisp/public/custom:ro
132+
- ./customization/nginx:/opt/openwisp/public/custom:ro
132133
networks:
133134
default:
134135
aliases:

docs/user/customization.rst

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ adding customizations. Execute these commands in the same location as the
2424
touch customization/configuration/django/__init__.py
2525
touch customization/configuration/django/custom_django_settings.py
2626
mkdir -p customization/theme
27+
mkdir -p customization/nginx
2728
2829
You can also refer to the `directory structure of Docker OpenWISP
2930
repository
@@ -84,16 +85,24 @@ follow the following guide.
8485
8586
2. Create your custom CSS / Javascript file in ``customization/theme``
8687
directory created in the above section. E.g.
87-
``customization/theme/static/custom/css/custom-theme.css``.
88-
3. Start the nginx containers.
88+
``customization/theme/custom/css/custom-theme.css``.
89+
3. Recreate the dashboard container to apply the changes:
90+
91+
.. code-block:: shell
92+
93+
docker compose up -d --force-recreate dashboard
8994
9095
.. note::
9196

92-
1. You can edit the styles / JavaScript files now without restarting
93-
the container, as long as file is in the correct place, it will be
94-
picked.
95-
2. You can create a ``maintenance.html`` file inside the ``customize``
96-
directory to have a custom maintenance page for scheduled downtime.
97+
After adding, updating, or removing files in ``customization/theme``,
98+
you must recreate the dashboard container using the command above.
99+
100+
Alternatively, you can apply changes without recreating the container
101+
by running:
102+
103+
.. code-block:: shell
104+
105+
docker compose exec dashboard bash -c "python collectstatic.py && uwsgi --reload uwsgi.pid"
97106
98107
Supplying Custom uWSGI configuration
99108
------------------------------------
@@ -175,6 +184,21 @@ Docker
175184
PATH/TO/YOUR/DEFAULT:/etc/raddb/sites-enabled/default
176185
...
177186
187+
Enabling Maintenance Mode
188+
~~~~~~~~~~~~~~~~~~~~~~~~~
189+
190+
To enable maintenance mode, create a ``maintenance.html`` file in the
191+
``customization/nginx/`` directory:
192+
193+
.. code-block:: shell
194+
195+
customization/nginx/maintenance.html
196+
197+
When this file is present, Nginx will automatically serve it instead of
198+
the application for incoming requests.
199+
200+
To disable maintenance mode, simply remove the file.
201+
178202
Supplying Custom Python Source Code
179203
-----------------------------------
180204

images/common/collectstatic.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,78 @@ def get_pip_freeze_hash():
2626
sys.exit(1)
2727

2828

29-
def run_collectstatic():
29+
def get_dir_shasum(directory_path):
30+
"""Return a sha256 hexdigest of all files (names + contents) under directory_path.
31+
32+
If the directory does not exist, return the hash of empty contents.
33+
"""
34+
if not os.path.exists(directory_path):
35+
return hashlib.sha256(b"").hexdigest()
36+
hasher = hashlib.sha256()
37+
for root, dirs, files in os.walk(directory_path):
38+
dirs.sort()
39+
files.sort()
40+
for fname in files:
41+
fpath = os.path.join(root, fname)
42+
relpath = os.path.relpath(fpath, directory_path)
43+
try:
44+
file_hasher = hashlib.sha256()
45+
with open(fpath, "rb") as fh:
46+
for chunk in iter(lambda: fh.read(4096), b""):
47+
file_hasher.update(chunk)
48+
relpath_bytes = relpath.encode()
49+
hasher.update(len(relpath_bytes).to_bytes(8, "big"))
50+
hasher.update(relpath_bytes)
51+
hasher.update(file_hasher.digest())
52+
except OSError:
53+
# If a file can't be read, skip it but continue hashing others
54+
continue
55+
return hasher.hexdigest()
56+
57+
58+
def run_collectstatic(clear=False):
3059
try:
31-
subprocess.run(
32-
[sys.executable, "manage.py", "collectstatic", "--noinput"], check=True
33-
)
60+
cmd = [sys.executable, "manage.py", "collectstatic", "--noinput"]
61+
if clear:
62+
cmd.append("--clear")
63+
subprocess.run(cmd, check=True)
3464
except subprocess.CalledProcessError as e:
3565
print(f"Error running 'collectstatic': {e}", file=sys.stderr)
3666
sys.exit(1)
3767

3868

3969
def main():
4070
if os.environ.get("COLLECTSTATIC_WHEN_DEPS_CHANGE", "true").lower() == "false":
41-
run_collectstatic()
71+
run_collectstatic(clear=True)
4272
return
4373
redis_connection = redis.Redis.from_url(settings.CACHES["default"]["LOCATION"])
4474
current_pip_hash = get_pip_freeze_hash()
75+
current_static_hash = get_dir_shasum(
76+
os.path.join(settings.BASE_DIR, "static_custom")
77+
)
4578
cached_pip_hash = redis_connection.get("pip_freeze_hash")
46-
if not cached_pip_hash or cached_pip_hash.decode() != current_pip_hash:
47-
print("Changes in Python dependencies detected, running collectstatic...")
48-
run_collectstatic()
49-
redis_connection.set("pip_freeze_hash", current_pip_hash)
79+
cached_static_hash = redis_connection.get("static_custom_hash")
80+
pip_changed = not cached_pip_hash or cached_pip_hash.decode() != current_pip_hash
81+
static_changed = (
82+
not cached_static_hash or cached_static_hash.decode() != current_static_hash
83+
)
84+
if pip_changed or static_changed:
85+
print(
86+
"Changes in Python dependencies or static_custom detected,"
87+
" running collectstatic..."
88+
)
89+
run_collectstatic(clear=static_changed)
90+
try:
91+
redis_connection.set("pip_freeze_hash", current_pip_hash)
92+
redis_connection.set("static_custom_hash", current_static_hash)
93+
except Exception:
94+
# If caching fails, don't crash the startup; collectstatic already ran
95+
pass
5096
else:
51-
print("No changes in Python dependencies, skipping collectstatic...")
97+
print(
98+
"No changes in Python dependencies or static_custom,"
99+
" skipping collectstatic..."
100+
)
52101

53102

54103
if __name__ == "__main__":

images/common/openwisp/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@
284284
# Static files (CSS, JavaScript, Images)
285285
# https://docs.djangoproject.com/en/1.9/howto/static-files/
286286

287+
STATICFILES_DIRS = [os.path.join(BASE_DIR, "static_custom")]
287288
STATIC_ROOT = os.path.join(BASE_DIR, "static")
288289
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
289290
# PRIVATE_STORAGE_ROOT path should be similar to ansible-openwisp2

tests/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
"username": "admin",
1010
"password": "admin",
1111
"services_max_retries": 25,
12-
"services_delay_retries": 5
12+
"services_delay_retries": 5,
13+
"custom_css_filename": "custom-openwisp-test.css"
1314
}

tests/runtests.py

Lines changed: 110 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,89 @@ def test_wait_for_services(self):
5959

6060

6161
class TestServices(TestUtilities, unittest.TestCase):
62+
custom_static_token = None
63+
6264
@property
6365
def failureException(self):
6466
TestServices.failed_test = True
6567
return super().failureException
6668

69+
@classmethod
70+
def _execute_docker_compose_command(cls, cmd_args, use_text_mode=False):
71+
"""Execute a docker compose command and log output.
72+
73+
Args:
74+
cmd_args: List of command arguments for subprocess.Popen
75+
use_text_mode: If True, use text mode for subprocess output
76+
77+
Returns:
78+
Tuple of (output, error) from command execution
79+
"""
80+
kwargs = {
81+
"stdout": subprocess.PIPE,
82+
"stderr": subprocess.PIPE,
83+
"cwd": cls.root_location,
84+
}
85+
if use_text_mode:
86+
kwargs["text"] = True
87+
cmd = subprocess.run(cmd_args, check=False, **kwargs)
88+
if use_text_mode:
89+
output, error = cmd.stdout, cmd.stderr
90+
else:
91+
output = cmd.stdout.decode("utf-8", errors="replace") if cmd.stdout else ""
92+
error = cmd.stderr.decode("utf-8", errors="replace") if cmd.stderr else ""
93+
output, error = map(str, (cmd.stdout, cmd.stderr))
94+
with open(cls.config["logs_file"], "a") as logs_file:
95+
logs_file.write(output)
96+
logs_file.write(error)
97+
if cmd.returncode != 0:
98+
raise RuntimeError(
99+
f"docker compose command failed "
100+
f"({cmd.returncode}): {' '.join(cmd_args)}"
101+
)
102+
return output, error
103+
104+
@classmethod
105+
def _setup_admin_theme_links(cls):
106+
"""Configure admin theme links during tests.
107+
108+
The default docker-compose setup does not allow injecting
109+
OPENWISP_ADMIN_THEME_LINKS dynamically, so this method updates
110+
Django settings inside the running container and reloads uWSGI.
111+
This enables the Selenium tests to verify that a custom static CSS
112+
file is served by the admin interface.
113+
"""
114+
css_path = os.path.join(
115+
cls.root_location,
116+
"customization",
117+
"theme",
118+
cls.config["custom_css_filename"],
119+
)
120+
cls.custom_static_token = str(time.time_ns())
121+
with open(css_path, "w") as custom_css_file:
122+
custom_css_file.write(
123+
f"body{{--openwisp-test: {cls.custom_static_token};}}"
124+
)
125+
script = rf"""
126+
grep -q OPENWISP_ADMIN_THEME_LINKS /opt/openwisp/openwisp/settings.py || \
127+
printf "\nOPENWISP_ADMIN_THEME_LINKS=[{{\"type\":\"text/css\",\"href\":\"/static/admin/css/openwisp.css\",\"rel\":\"stylesheet\",\"media\":\"all\"}},{{\"type\":\"text/css\",\"href\":\"/static/{cls.config["custom_css_filename"]}\",\"rel\":\"stylesheet\",\"media\":\"all\"}},{{\"type\":\"image/x-icon\",\"href\":\"ui/openwisp/images/favicon.png\",\"rel\":\"icon\"}}]\n" >> /opt/openwisp/openwisp/settings.py &&
128+
python collectstatic.py &&
129+
uwsgi --reload uwsgi.pid
130+
""" # noqa: E501
131+
cls._execute_docker_compose_command(
132+
[
133+
"docker",
134+
"compose",
135+
"exec",
136+
"-T",
137+
"dashboard",
138+
"bash",
139+
"-c",
140+
script,
141+
],
142+
use_text_mode=True,
143+
)
144+
67145
@classmethod
68146
def setUpClass(cls):
69147
cls.failed_test = False
@@ -76,7 +154,7 @@ def setUpClass(cls):
76154
os.path.dirname(os.path.realpath(__file__)), "data.py"
77155
)
78156
entrypoint = "python manage.py shell --command='import data; data.setup()'"
79-
cmd = subprocess.Popen(
157+
cls._execute_docker_compose_command(
80158
[
81159
"docker",
82160
"compose",
@@ -87,22 +165,12 @@ def setUpClass(cls):
87165
"--volume",
88166
f"{test_data_file}:/opt/openwisp/data.py",
89167
"dashboard",
90-
],
91-
universal_newlines=True,
92-
stdout=subprocess.PIPE,
93-
stderr=subprocess.PIPE,
94-
cwd=cls.root_location,
168+
]
95169
)
96-
output, error = map(str, cmd.communicate())
97-
with open(cls.config["logs_file"], "w") as logs_file:
98-
logs_file.write(output)
99-
logs_file.write(error)
100-
subprocess.run(
170+
cls._execute_docker_compose_command(
101171
["docker", "compose", "up", "--detach"],
102-
stdout=subprocess.DEVNULL,
103-
stderr=subprocess.DEVNULL,
104-
cwd=cls.root_location,
105172
)
173+
cls._setup_admin_theme_links()
106174
# Create base drivers (Firefox)
107175
if cls.config["driver"] == "firefox":
108176
cls.base_driver = cls.get_firefox_webdriver()
@@ -122,6 +190,15 @@ def tearDownClass(cls):
122190
print(f"Unable to delete resource at: {resource_link}")
123191
cls.second_driver.quit()
124192
cls.base_driver.quit()
193+
# Remove the temporary custom CSS file created for testing
194+
css_path = os.path.join(
195+
cls.root_location,
196+
"customization",
197+
"theme",
198+
cls.config["custom_css_filename"],
199+
)
200+
if os.path.exists(css_path):
201+
os.remove(css_path)
125202
if cls.failed_test and cls.config["logs"]:
126203
cmd = subprocess.Popen(
127204
["docker", "compose", "logs"],
@@ -156,6 +233,16 @@ def test_admin_login(self):
156233
)
157234
self.fail(message)
158235

236+
def test_custom_static_files_loaded(self):
237+
self.login()
238+
self.open("/admin/")
239+
# Check if the custom CSS variable is applied
240+
value = self.web_driver.execute_script(
241+
"return getComputedStyle(document.body)"
242+
".getPropertyValue('--openwisp-test');"
243+
)
244+
self.assertEqual(value.strip(), self.custom_static_token)
245+
159246
def test_device_monitoring_charts(self):
160247
self.login()
161248
self.get_resource("test-device", "/admin/config/device/")
@@ -235,9 +322,17 @@ def test_forgot_password(self):
235322
"""Test forgot password to ensure that postfix is working properly."""
236323

237324
self.logout()
325+
try:
326+
WebDriverWait(self.base_driver, 3).until(
327+
EC.text_to_be_present_in_element(
328+
(By.CSS_SELECTOR, ".title-wrapper h1"), "Logged out"
329+
)
330+
)
331+
except TimeoutException:
332+
self.fail("Logout failed.")
238333
self.open("/accounts/password/reset/")
239334
self.find_element(By.NAME, "email").send_keys("admin@example.com")
240-
self.find_element(By.XPATH, '//button[@type="submit"]').click()
335+
self.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()
241336
self._wait_until_page_ready()
242337
self.assertIn(
243338
"We have sent you an email. If you have not received "

0 commit comments

Comments
 (0)