Skip to content

Commit b10d3cd

Browse files
authored
[feature] Added CopyableFieldsAdmin openwisp#324
Closes openwisp#324
1 parent 04a2bcb commit b10d3cd

File tree

6 files changed

+140
-31
lines changed

6 files changed

+140
-31
lines changed

README.rst

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -937,11 +937,21 @@ is created even if the default values are unchanged.
937937
Without this, when creating new objects, inline items won't be saved
938938
unless users change the default values.
939939

940+
``openwisp_utils.admin.CopyableFieldsAdmin``
941+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
942+
943+
An admin class that allows to set admin fields to be
944+
read-only and makes it easy to copy the fields contents.
945+
946+
Useful for auto-generated fields such as UUIDs, secret keys, tokens, etc.
947+
940948
``openwisp_utils.admin.UUIDAdmin``
941949
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
942950

943-
An admin class that provides the UUID of the object as a read-only input field
944-
(to make it easy and quick to copy/paste).
951+
This class is a subclass of ``CopyableFieldsAdmin`` which
952+
sets ``uuid`` as the only copyable field. This class is kept
953+
for backward compatibility and convenience, since different models
954+
of various OpenWISP modules show ``uuid`` as the only copyable field.
945955

946956
``openwisp_utils.admin.ReceiveUrlAdmin``
947957
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

openwisp_utils/admin.py

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.contrib.admin import ModelAdmin, StackedInline
2+
from django.core.exceptions import FieldError
23
from django.urls import reverse
34
from django.utils.translation import gettext_lazy as _
45

@@ -69,33 +70,86 @@ def has_changed(self):
6970
return super().has_changed()
7071

7172

72-
class UUIDAdmin(ModelAdmin):
73+
class CopyableFieldError(FieldError):
74+
pass
75+
76+
77+
class CopyableFieldsAdmin(ModelAdmin):
7378
"""
74-
Defines a field name uuid whose value is that
75-
of the id of the object
79+
An admin class that allows to set admin
80+
fields to be read-only and makes it easy
81+
to copy the fields contents.
82+
Useful for auto-generated fields such as
83+
UUIDs, secret keys, tokens, etc
7684
"""
7785

78-
def uuid(self, obj):
79-
return obj.pk
80-
81-
def _process_fields(self, fields, request, obj):
82-
fields = list(fields)
83-
if 'uuid' in fields and not obj:
84-
fields.remove('uuid')
85-
if 'uuid' not in fields and obj:
86-
fields.insert(0, 'uuid')
87-
return tuple(fields)
86+
copyable_fields = ()
87+
change_form_template = 'admin/change_form.html'
88+
89+
def _check_copyable_subset_fields(self, copyable_fields, fields):
90+
if not set(copyable_fields).issubset(fields):
91+
class_name = self.__class__.__name__
92+
raise CopyableFieldError(
93+
(
94+
f'{copyable_fields} not in {class_name}.fields {fields}, '
95+
f'Check copyable_fields attribute of class {class_name}.'
96+
)
97+
)
8898

8999
def get_fields(self, request, obj=None):
90-
fields = super().get_fields(request, obj)
91-
return self._process_fields(fields, request, obj)
100+
fields = super(ModelAdmin, self).get_fields(request, obj)
101+
self._check_copyable_subset_fields(self.copyable_fields, fields)
102+
# We should exclude `copyable_fields` fields
103+
# when the object doesn't exist for example,
104+
# in the case of `add_view` because `copyable_fields`
105+
# can't be edited and are auto generated by the system
106+
if not obj:
107+
return tuple(set(fields).difference(self.copyable_fields))
108+
return fields
92109

93110
def get_readonly_fields(self, request, obj=None):
94-
fields = super().get_readonly_fields(request, obj)
95-
return self._process_fields(fields, request, obj)
111+
readonly_fields = super(ModelAdmin, self).get_readonly_fields(request, obj)
112+
if not obj:
113+
return readonly_fields
114+
# Make sure `copyable_fields` is included in `read_only` fields
115+
return tuple([*readonly_fields, *self.copyable_fields])
116+
117+
def add_view(self, request, form_url='', extra_context=None):
118+
extra_context = extra_context or {}
119+
extra_context['copyable_fields'] = []
120+
return super().add_view(
121+
request,
122+
form_url,
123+
extra_context=extra_context,
124+
)
125+
126+
def change_view(self, request, object_id, form_url='', extra_context=None):
127+
extra_context = extra_context or {}
128+
extra_context['copyable_fields'] = list(self.copyable_fields)
129+
return super().change_view(
130+
request,
131+
object_id,
132+
form_url,
133+
extra_context=extra_context,
134+
)
96135

97136
class Media:
98-
js = ('admin/js/jquery.init.js', 'openwisp-utils/js/uuid.js')
137+
js = ('admin/js/jquery.init.js', 'openwisp-utils/js/copyable.js')
138+
139+
140+
class UUIDAdmin(CopyableFieldsAdmin):
141+
"""
142+
This class is a subclass of `CopyableFieldsAdmin`
143+
which sets `uuid` as the only copyable field
144+
This class is kept for backward compatibility
145+
and convenience, since different models of various
146+
OpenWISP modules show `uuid` as the only copyable field
147+
"""
148+
149+
copyable_fields = ('uuid',)
150+
151+
def uuid(self, obj):
152+
return obj.pk
99153

100154
uuid.short_description = _('UUID')
101155

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% extends "admin/change_form.html" %}
2+
{% block admin_change_form_document_ready %}
3+
{{ block.super }}
4+
{% if copyable_fields %}
5+
<script>
6+
window.copyableFields = {{ copyable_fields | safe }} || [];
7+
</script>
8+
{% endif %}
9+
{% endblock %}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use strict';
2+
django.jQuery(function ($) {
3+
if (window.copyableFields) {
4+
window.copyableFields.forEach(copyableField => {
5+
var copyableFieldContainer = $(`.field-${copyableField} .readonly`).eq(0);
6+
7+
copyableFieldContainer.html(`<input readonly id="id_${copyableField}" type="text"
8+
class="vTextField readonly" value="${copyableFieldContainer.text()}">`);
9+
10+
var copyableFieldSelectedId = $(`#id_${copyableField}`);
11+
copyableFieldSelectedId.click(function () {
12+
$(this).select();
13+
});
14+
});
15+
}
16+
});

openwisp_utils/static/openwisp-utils/js/uuid.js

Lines changed: 0 additions & 10 deletions
This file was deleted.

tests/test_project/tests/test_admin.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.core.exceptions import ImproperlyConfigured
77
from django.test import TestCase
88
from django.urls import reverse
9-
from openwisp_utils.admin import ReadOnlyAdmin
9+
from openwisp_utils.admin import CopyableFieldError, CopyableFieldsAdmin, ReadOnlyAdmin
1010
from openwisp_utils.admin_theme import settings as admin_theme_settings
1111
from openwisp_utils.admin_theme.apps import OpenWispAdminThemeConfig, _staticfy
1212
from openwisp_utils.admin_theme.checks import admin_theme_settings_checks
@@ -204,6 +204,36 @@ def test_uuid_field_in_add(self):
204204
self.assertNotContains(response, 'field-uuid')
205205
self.assertContains(response, 'field-receive_url')
206206

207+
def test_copyablefields_admin(self):
208+
class TestCopyableFieldAdmin(CopyableFieldsAdmin):
209+
copyable_fields = ('session_id', 'username')
210+
211+
options = dict(username='bobby', session_id='1')
212+
radius_acc = self._create_radius_accounting(**options)
213+
ma = TestCopyableFieldAdmin(RadiusAccounting, AdminSite)
214+
path = reverse(
215+
'admin:test_project_radiusaccounting_change', args=[radius_acc.pk]
216+
)
217+
self.assertEqual(
218+
ma.get_readonly_fields(self.client.request, radius_acc),
219+
TestCopyableFieldAdmin.copyable_fields,
220+
)
221+
response = self.client.get(path)
222+
self.assertEqual(response.status_code, 200)
223+
self.assertContains(response, 'field-username')
224+
self.assertContains(response, 'field-session_id')
225+
226+
def test_invalid_copyablefields_admin_error(self):
227+
class TestCopyableFieldAdmin(CopyableFieldsAdmin):
228+
pass
229+
230+
ma = TestCopyableFieldAdmin(Project, AdminSite)
231+
ma.copyable_fields = ('invalid_field',)
232+
copyable_field_err = "('invalid_field',) not in TestCopyableFieldAdmin.fields"
233+
with self.assertRaises(CopyableFieldError) as err:
234+
ma.get_fields(self.client.request)
235+
self.assertIn(copyable_field_err, err.exception.args[0])
236+
207237
def test_receive_url_admin(self):
208238
p = Project.objects.create(name='test_receive_url_admin_project')
209239
ma = ProjectAdmin(Project, self.site)

0 commit comments

Comments
 (0)