Skip to content

Commit 728ab65

Browse files
committed
[fix:ux] Fixed rendering of JSON fields in readonly mode #848
Fixes #848
1 parent 6ae8103 commit 728ab65

File tree

4 files changed

+313
-5
lines changed

4 files changed

+313
-5
lines changed

openwisp_controller/config/admin.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@
22
import logging
33
from collections.abc import Iterable
44

5+
import jsonfield
56
import reversion
67
from django import forms
78
from django.conf import settings
89
from django.contrib import admin, messages
910
from django.contrib.admin import helpers
1011
from django.contrib.admin.actions import delete_selected
1112
from django.contrib.admin.models import ADDITION, LogEntry
13+
from django.contrib.auth import get_permission_codename
1214
from django.contrib.contenttypes.models import ContentType
1315
from django.core.exceptions import (
1416
FieldDoesNotExist,
1517
ObjectDoesNotExist,
1618
ValidationError,
1719
)
20+
from django.db import models
1821
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
1922
from django.http.response import HttpResponseForbidden
2023
from django.shortcuts import get_object_or_404
@@ -45,7 +48,7 @@
4548
from .exportable import DeviceResource
4649
from .filters import DeviceGroupFilter, GroupFilter, TemplatesFilter
4750
from .utils import send_file
48-
from .widgets import DeviceGroupJsonSchemaWidget, JsonSchemaWidget
51+
from .widgets import DeviceGroupJsonSchemaWidget, JsonSchemaWidget, ReadOnlyJsonWidget
4952

5053
logger = logging.getLogger(__name__)
5154
prefix = "config/"
@@ -102,7 +105,63 @@ def get_extra(self, request, obj=None, **kwargs):
102105
return super().get_extra(request, obj, **kwargs)
103106

104107

105-
class BaseConfigAdmin(BaseAdmin):
108+
class ReadOnlyJsonFieldMixin(object):
109+
"""
110+
Mixin to display JSON fields as read-only for users without change permission.
111+
112+
This mixin overrides the default widgets for JSON fields, replacing them with
113+
read-only alternatives when the user lacks permission to modify the fields.
114+
115+
It works around a Django limitation that prevents a single widget from being reused
116+
in both editable and read-only contexts.
117+
118+
For context, see: https://code.djangoproject.com/ticket/30577#comment:10
119+
"""
120+
121+
def _change_json_fields_widgets(self, obj, form, can_change):
122+
if obj and not can_change:
123+
for field in form._meta.fields:
124+
try:
125+
if isinstance(
126+
self.model._meta.get_field(field),
127+
(models.JSONField, jsonfield.JSONField),
128+
):
129+
form.base_fields[field] = forms.JSONField(
130+
widget=ReadOnlyJsonWidget, disabled=True
131+
)
132+
except FieldDoesNotExist:
133+
# A field could be a Model property or ModelAdmin method,
134+
# which does not exist in the model.
135+
continue
136+
137+
def get_formset(self, request, obj=None, **kwargs):
138+
"""
139+
This method is used by the inline admin to generate the form.
140+
"""
141+
formset = super().get_formset(request, obj, **kwargs)
142+
form = formset.form
143+
# The user should have change permission on both the parent model
144+
# and the inline model to be able to change the JSON fields.
145+
can_change = self.has_change_permission(request, obj) and request.user.has_perm(
146+
"{}.{}".format(
147+
self.parent_model._meta.app_label,
148+
get_permission_codename("change", self.parent_model._meta),
149+
)
150+
)
151+
self._change_json_fields_widgets(obj, form, can_change)
152+
return formset
153+
154+
def get_form(self, request, obj=None, **kwargs):
155+
"""
156+
This method is used by the ModelAdmin to generate the form.
157+
"""
158+
form = super().get_form(request, obj, **kwargs)
159+
can_change = self.has_change_permission(request, obj)
160+
self._change_json_fields_widgets(obj, form, can_change)
161+
return form
162+
163+
164+
class BaseConfigAdmin(ReadOnlyJsonFieldMixin, BaseAdmin):
106165
change_form_template = "admin/config/change_form.html"
107166
preview_template = None
108167
actions_on_bottom = True
@@ -420,6 +479,7 @@ class Meta(BaseForm.Meta):
420479

421480

422481
class ConfigInline(
482+
ReadOnlyJsonFieldMixin,
423483
DeactivatedDeviceReadOnlyMixin,
424484
MultitenantAdminMixin,
425485
TimeReadonlyAdminMixin,
@@ -1265,7 +1325,7 @@ class Meta(BaseForm.Meta):
12651325
widgets = {"meta_data": DeviceGroupJsonSchemaWidget, "context": FlatJsonWidget}
12661326

12671327

1268-
class DeviceGroupAdmin(MultitenantAdminMixin, BaseAdmin):
1328+
class DeviceGroupAdmin(MultitenantAdminMixin, ReadOnlyJsonFieldMixin, BaseAdmin):
12691329
change_form_template = "admin/device_group/change_form.html"
12701330
form = DeviceGroupForm
12711331
list_display = [
@@ -1359,7 +1419,7 @@ class ConfigSettingsForm(AlwaysHasChangedMixin, forms.ModelForm):
13591419
class Meta:
13601420
widgets = {"context": FlatJsonWidget}
13611421

1362-
class ConfigSettingsInline(admin.StackedInline):
1422+
class ConfigSettingsInline(ReadOnlyJsonFieldMixin, admin.StackedInline):
13631423
model = OrganizationConfigSettings
13641424
form = ConfigSettingsForm
13651425

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
pre.readonly-json-widget {
2+
margin-top: 0px;
3+
color: var(--body-loud-color);
4+
background: var(--darkened-bg);
5+
padding: 6px 10px;
6+
font-size: inherit;
7+
margin-top: 0;
8+
}
9+
.readonly:has(pre.readonly-json-widget) {
10+
width: 100%;
11+
overflow: auto;
12+
padding-top: 0 !important;
13+
}

openwisp_controller/config/tests/test_selenium.py

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import json
12
import time
23

4+
from django.contrib.auth.models import Permission
35
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
46
from django.test import tag
57
from django.urls.base import reverse
@@ -13,10 +15,16 @@
1315

1416
from openwisp_utils.tests import SeleniumTestMixin as BaseSeleniumTestMixin
1517

16-
from .utils import CreateConfigTemplateMixin, TestVpnX509Mixin, TestWireguardVpnMixin
18+
from .utils import (
19+
CreateConfigTemplateMixin,
20+
CreateDeviceGroupMixin,
21+
TestVpnX509Mixin,
22+
TestWireguardVpnMixin,
23+
)
1724

1825
Device = load_model("config", "Device")
1926
DeviceGroup = load_model("config", "DeviceGroup")
27+
OrganizationConfigSettings = load_model("config", "OrganizationConfigSettings")
2028
Cert = load_model("django_x509", "Cert")
2129

2230

@@ -44,6 +52,80 @@ def _verify_templates_visibility(self, hidden=None, visible=None):
4452
for template in visible:
4553
self.wait_for_visibility(By.XPATH, f'//*[@value="{template.id}"]')
4654

55+
def _create_readonly_user(
56+
self, username="readonly_user", email="[email protected]", organization=None
57+
):
58+
"""
59+
Creates a readonly user with staff privileges and view-only permissions.
60+
Returns the user object.
61+
"""
62+
readonly_user = self._create_user(username=username, email=email, is_staff=True)
63+
org = organization or self._get_org()
64+
self._create_org_user(user=readonly_user, organization=org, is_admin=True)
65+
readonly_user.user_permissions.add(
66+
*Permission.objects.filter(
67+
codename__in=[
68+
"view_device",
69+
"view_template",
70+
"view_vpn",
71+
"view_config",
72+
"view_devicegroup",
73+
"view_organization",
74+
"view_organizationconfigsettings",
75+
]
76+
)
77+
)
78+
return readonly_user
79+
80+
def _test_readonly_json_fields(
81+
self,
82+
url,
83+
field_selectors,
84+
scroll_to_bottom=True,
85+
hide_loading_overlay=True,
86+
user=None,
87+
):
88+
"""
89+
Reusable method to test readonly JSON fields rendering.
90+
91+
Args:
92+
url: The URL to open for testing
93+
field_selectors: Dictionary where key is CSS selector and value is
94+
expected text content
95+
scroll_to_bottom: Whether to scroll to bottom of page (default: True)
96+
user: User object to login as. If None, creates a readonly user
97+
(default: None)
98+
"""
99+
if user is None:
100+
org = self._get_org()
101+
user = self._create_readonly_user(organization=org)
102+
103+
self.login(username=user.username, password="tester")
104+
self.open(url)
105+
if hide_loading_overlay:
106+
self.hide_loading_overlay()
107+
108+
if scroll_to_bottom:
109+
self.web_driver.execute_script(
110+
"window.scrollTo(0, document.body.scrollHeight);"
111+
)
112+
113+
for css_selector, expected_content in field_selectors.items():
114+
readonly_element = self.find_element(
115+
by=By.CSS_SELECTOR,
116+
value=css_selector,
117+
)
118+
self.assertEqual(readonly_element.is_displayed(), True)
119+
if isinstance(expected_content, dict):
120+
# If expected_content is a dict, format it as JSON
121+
self.assertEqual(
122+
readonly_element.text,
123+
json.dumps(expected_content, indent=4),
124+
)
125+
else:
126+
# Otherwise, check if the text contains the expected content
127+
self.assertIn(expected_content, readonly_element.text)
128+
47129

48130
@tag("selenium_tests")
49131
class TestDeviceAdmin(
@@ -373,11 +455,73 @@ def test_add_remove_templates(self):
373455
self.assertEqual(config.templates.count(), 0)
374456
self.assertEqual(config.status, "modified")
375457

458+
def test_readonly_config_fields(self):
459+
"""
460+
Test that configuration variables and configuration render properly
461+
when the device only has read only permission.
462+
"""
463+
org = self._get_org()
464+
readonly_user = self._create_readonly_user(organization=org)
465+
466+
template = self._create_template(
467+
organization=org,
468+
default_values={"mac_address": "00:00:00:00:00:00", "ssid": "OpenWisp"},
469+
config={
470+
"interfaces": [
471+
{
472+
"name": "wlan0",
473+
"network": "br-lan",
474+
"type": "wireless",
475+
"wireless": {
476+
"mode": "access_point",
477+
"radio": "radio0",
478+
"ssid": "{{ ssid }}",
479+
},
480+
}
481+
]
482+
},
483+
)
484+
device = self._create_device(organization=org)
485+
config = self._create_config(
486+
device=device,
487+
context={"hostname": "readonly-device", "ssid": "ReadOnlyWiFi"},
488+
)
489+
config.templates.add(template)
490+
491+
with self.subTest("Template default values and config rendered as readonly"):
492+
template_url = reverse("admin:config_template_change", args=[template.id])
493+
template_selectors = {
494+
".field-default_values .readonly pre.readonly-json-widget": (
495+
template.default_values
496+
),
497+
".field-config .readonly pre.readonly-json-widget": template.config,
498+
}
499+
self._test_readonly_json_fields(
500+
url=template_url, field_selectors=template_selectors, user=readonly_user
501+
)
502+
503+
with self.subTest("Device configuration variables rendered as readonly"):
504+
device_url = (
505+
reverse("admin:config_device_change", args=[device.id])
506+
+ "#config-group"
507+
)
508+
device_selectors = {
509+
".field-context .readonly pre.readonly-json-widget": {
510+
"hostname": "readonly-device",
511+
"ssid": "ReadOnlyWiFi",
512+
},
513+
".field-config .readonly pre.readonly-json-widget": config.config,
514+
}
515+
self._test_readonly_json_fields(
516+
url=device_url, field_selectors=device_selectors, user=readonly_user
517+
)
518+
376519

377520
@tag("selenium_tests")
378521
class TestDeviceGroupAdmin(
379522
SeleniumTestMixin,
380523
CreateConfigTemplateMixin,
524+
CreateDeviceGroupMixin,
381525
StaticLiveServerTestCase,
382526
):
383527
def test_show_relevant_templates(self):
@@ -476,6 +620,35 @@ def test_show_relevant_templates(self):
476620
False,
477621
)
478622

623+
def test_readonly_devicegroup(self):
624+
"""
625+
Test that device group context renders properly
626+
when the user only has read only permission.
627+
"""
628+
org = self._get_org()
629+
readonly_user = self._create_readonly_user(organization=org)
630+
device_group = self._create_device_group(
631+
name="readonly-group",
632+
organization=org,
633+
context={"mesh_id": "readonly-mesh", "vni": "100"},
634+
)
635+
636+
device_group_url = reverse(
637+
"admin:config_devicegroup_change", args=[device_group.id]
638+
)
639+
device_group_selectors = {
640+
".field-context .readonly pre.readonly-json-widget": device_group.context,
641+
".field-meta_data .readonly pre.readonly-json-widget": (
642+
device_group.meta_data
643+
),
644+
}
645+
self._test_readonly_json_fields(
646+
url=device_group_url,
647+
field_selectors=device_group_selectors,
648+
user=readonly_user,
649+
hide_loading_overlay=False,
650+
)
651+
479652

480653
@tag("selenium_tests")
481654
class TestDeviceAdminUnsavedChanges(
@@ -618,3 +791,43 @@ def test_vpn_edit(self):
618791
backend.select_by_visible_text("OpenVPN")
619792
self.wait_for_invisibility(by=By.CLASS_NAME, value="field-webhook_endpoint")
620793
self.wait_for_invisibility(by=By.CLASS_NAME, value="field-auth_token")
794+
795+
def test_readonly_vpn_config(self):
796+
"""
797+
Test that VPN configuration renders properly
798+
when the user only has read only permission.
799+
"""
800+
org = self._get_org()
801+
readonly_user = self._create_readonly_user(organization=org)
802+
vpn = self._create_wireguard_vpn(organization=org)
803+
804+
vpn_url = reverse("admin:config_vpn_change", args=[vpn.id])
805+
vpn_selectors = {
806+
".field-config .readonly pre.readonly-json-widget": vpn.config,
807+
}
808+
self._test_readonly_json_fields(
809+
url=vpn_url, field_selectors=vpn_selectors, user=readonly_user
810+
)
811+
812+
813+
@tag("selenium_tests")
814+
class TestOrganizationConfigSettingsInlineAdmin(
815+
SeleniumTestMixin, CreateConfigTemplateMixin, StaticLiveServerTestCase
816+
):
817+
def test_organization_config_settings_readonly_fields(self):
818+
org = self._get_org()
819+
config_settings = OrganizationConfigSettings.objects.create(
820+
organization=org,
821+
context={"key1": "value1", "key2": "value2"},
822+
)
823+
readonly_user = self._create_readonly_user(organization=org)
824+
self._test_readonly_json_fields(
825+
url=reverse("admin:openwisp_users_organization_change", args=[org.id]),
826+
field_selectors={
827+
".field-context .readonly pre.readonly-json-widget": (
828+
config_settings.context
829+
),
830+
},
831+
user=readonly_user,
832+
hide_loading_overlay=False,
833+
)

0 commit comments

Comments
 (0)