Skip to content

Commit 27f544f

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

File tree

4 files changed

+290
-5
lines changed

4 files changed

+290
-5
lines changed

openwisp_controller/config/admin.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.contrib.admin import helpers
1010
from django.contrib.admin.actions import delete_selected
1111
from django.contrib.admin.models import ADDITION, LogEntry
12+
from django.contrib.auth import get_permission_codename
1213
from django.contrib.contenttypes.models import ContentType
1314
from django.core.exceptions import (
1415
FieldDoesNotExist,
@@ -45,7 +46,7 @@
4546
from .exportable import DeviceResource
4647
from .filters import DeviceGroupFilter, GroupFilter, TemplatesFilter
4748
from .utils import send_file
48-
from .widgets import DeviceGroupJsonSchemaWidget, JsonSchemaWidget
49+
from .widgets import DeviceGroupJsonSchemaWidget, JsonSchemaWidget, ReadOnlyJsonWidget
4950

5051
logger = logging.getLogger(__name__)
5152
prefix = "config/"
@@ -102,7 +103,42 @@ def get_extra(self, request, obj=None, **kwargs):
102103
return super().get_extra(request, obj, **kwargs)
103104

104105

105-
class BaseConfigAdmin(BaseAdmin):
106+
class ReadOnlyJsonFieldMixin(object):
107+
def _change_json_fields_widgets(self, request, obj, form, can_change):
108+
if obj and not can_change:
109+
widgets = getattr(form._meta, "widgets", {}) or {}
110+
for field, widget in widgets.items():
111+
if issubclass(widget, (JsonSchemaWidget, FlatJsonWidget)):
112+
form.base_fields[field] = forms.JSONField(
113+
widget=ReadOnlyJsonWidget, disabled=True
114+
)
115+
116+
def get_formset(self, request, obj=None, **kwargs):
117+
"""
118+
This method is used by the inline admin to generate the form.
119+
"""
120+
formset = super().get_formset(request, obj, **kwargs)
121+
form = formset.form
122+
can_change = self.has_change_permission(request, obj) and request.user.has_perm(
123+
"{}.{}".format(
124+
self.parent_model._meta.app_label,
125+
get_permission_codename("change", self.parent_model._meta),
126+
)
127+
)
128+
self._change_json_fields_widgets(request, obj, form, can_change)
129+
return formset
130+
131+
def get_form(self, request, obj=None, **kwargs):
132+
"""
133+
This method is used by the ModelAdmin to generate the form.
134+
"""
135+
form = super().get_form(request, obj, **kwargs)
136+
can_change = self.has_change_permission(request, obj)
137+
self._change_json_fields_widgets(request, obj, form, can_change)
138+
return form
139+
140+
141+
class BaseConfigAdmin(ReadOnlyJsonFieldMixin, BaseAdmin):
106142
change_form_template = "admin/config/change_form.html"
107143
preview_template = None
108144
actions_on_bottom = True
@@ -420,6 +456,7 @@ class Meta(BaseForm.Meta):
420456

421457

422458
class ConfigInline(
459+
ReadOnlyJsonFieldMixin,
423460
DeactivatedDeviceReadOnlyMixin,
424461
MultitenantAdminMixin,
425462
TimeReadonlyAdminMixin,
@@ -1265,7 +1302,7 @@ class Meta(BaseForm.Meta):
12651302
widgets = {"meta_data": DeviceGroupJsonSchemaWidget, "context": FlatJsonWidget}
12661303

12671304

1268-
class DeviceGroupAdmin(MultitenantAdminMixin, BaseAdmin):
1305+
class DeviceGroupAdmin(MultitenantAdminMixin, ReadOnlyJsonFieldMixin, BaseAdmin):
12691306
change_form_template = "admin/device_group/change_form.html"
12701307
form = DeviceGroupForm
12711308
list_display = [
@@ -1359,7 +1396,7 @@ class ConfigSettingsForm(AlwaysHasChangedMixin, forms.ModelForm):
13591396
class Meta:
13601397
widgets = {"context": FlatJsonWidget}
13611398

1362-
class ConfigSettingsInline(admin.StackedInline):
1399+
class ConfigSettingsInline(ReadOnlyJsonFieldMixin, admin.StackedInline):
13631400
model = OrganizationConfigSettings
13641401
form = ConfigSettingsForm
13651402

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+
)

openwisp_controller/config/widgets.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
1+
import json
2+
13
from django import forms
24
from django.contrib.admin.widgets import AdminTextareaWidget
5+
from django.forms.widgets import Widget
36
from django.template.loader import get_template
47
from django.urls import reverse
8+
from django.utils.safestring import mark_safe
59
from swapper import load_model
610

711
DeviceGroup = load_model("config", "DeviceGroup")
812

913

14+
class ReadOnlyJsonWidget(Widget):
15+
read_only = True
16+
17+
@property
18+
def media(self):
19+
css = {
20+
"all": [
21+
"config/css/readonly-json-widget.css",
22+
]
23+
}
24+
return forms.Media(css=css)
25+
26+
def render(self, name, value, attrs=None, renderer=None):
27+
value = value or {}
28+
value = json.dumps(value, indent=4, sort_keys=True)
29+
return mark_safe(f'<pre class="readonly-json-widget">{value}</pre>')
30+
31+
1032
class JsonSchemaWidget(AdminTextareaWidget):
1133
"""
1234
JSON Schema Editor widget

0 commit comments

Comments
 (0)