Skip to content

Commit c45a51f

Browse files
committed
[feature] Improve the mass command admin action
- Move the action into BatchCommandAdmin as a static method guarded by the device change permission - Hide the target fields when the command runs system wide - Move the selection warning to the messages framework - Add unit and selenium tests for the organization scope and the permissions of the action
1 parent 17ebcd1 commit c45a51f

8 files changed

Lines changed: 360 additions & 87 deletions

File tree

docs/user/shell-commands.rst

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,15 @@ A mass command can also be started from the device list: select the
221221
devices with their checkboxes, choose *Execute mass command* from the
222222
actions dropdown and click *Go*.
223223

224-
The first step opens with the selection already applied: the devices it
225-
will run on are shown above the targets, and the organization is filled in
226-
and cannot be changed, while device group and location are not asked for,
227-
since the devices are already known.
224+
The first step opens with the selection already applied: a message at the
225+
top of the page states how many devices the command will run on, and the
226+
organization is filled in and cannot be changed, while device group and
227+
location are not asked for, since the devices are already known.
228228

229229
The selected devices must belong to the same organization, otherwise the
230-
action refuses to start.
230+
action refuses to start. The exception is a superuser selecting every
231+
device of the system: the command then runs on all of them and no target
232+
is asked for.
231233

232234
The rest of the workflow is the same as described below: the devices can
233235
still be reviewed and left out before executing.

openwisp_controller/connection/admin.py

Lines changed: 54 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,12 @@ class Media:
103103
]
104104
}
105105

106-
def __init__(self, *args, request=None, device_ids=None, **kwargs):
106+
def __init__(
107+
self, *args, request=None, device_ids=None, system_wide=False, **kwargs
108+
):
107109
super().__init__(*args, **kwargs)
108110
self.request = request
111+
self.system_wide = system_wide
109112
self.device_ids = self._scope_devices(device_ids)
110113
if self.device_ids:
111114
self.fields["devices"].initial = ",".join(self.device_ids)
@@ -167,10 +170,7 @@ def clean(self):
167170
)
168171
if len(self._organization_ids) > 1:
169172
raise ValidationError(
170-
_(
171-
"All devices must belong to the same organization,"
172-
" unless it is a system wide command."
173-
)
173+
_("All devices must belong to the same organization.")
174174
)
175175
if self.request is None or self.request.user.is_superuser:
176176
return cleaned_data
@@ -195,13 +195,12 @@ def fieldsets(self):
195195
fields: the sections are listed here, in the order they are shown,
196196
and the template renders each field with the admin markup.
197197
"""
198-
targets = ("organization",)
199-
if not self.device_ids:
200-
targets += ("location", "group")
201-
sections = (
202-
(_("Command"), ("type", "input", "label", "notes")),
203-
(_("Targets"), targets),
204-
)
198+
sections = [(_("Command"), ("type", "input", "label", "notes"))]
199+
if not self.system_wide:
200+
targets = ("organization",)
201+
if not self.device_ids:
202+
targets += ("location", "group")
203+
sections.append((_("Targets"), targets))
205204
return [
206205
(title, [self[field_name] for field_name in field_names])
207206
for title, field_names in sections
@@ -620,14 +619,27 @@ def execute_command_view(self, request):
620619
return self._render_execute_page(request, form)
621620

622621
def _render_execute_page(self, request, form):
622+
device_count = len(form.device_ids)
623+
if device_count:
624+
messages.warning(
625+
request,
626+
ngettext(
627+
"The command will run on the device you selected.",
628+
"The command will run on the %(count)d devices you selected.",
629+
device_count,
630+
)
631+
% {"count": device_count},
632+
)
633+
elif form.system_wide:
634+
messages.warning(request, _("The command will run on all devices."))
623635
context = {
624636
**self.admin_site.each_context(request),
625637
"title": _("Execute mass command"),
626638
"opts": self.opts,
627639
"form": form,
628640
"media": form.media,
629641
"has_view_permission": self.has_view_permission(request),
630-
"device_count": len(form.device_ids),
642+
"device_count": device_count,
631643
}
632644
return TemplateResponse(request, self.execute_command_template, context)
633645

@@ -1172,46 +1184,35 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
11721184
)
11731185
return super().change_view(request, object_id, extra_context=extra_context)
11741186

1175-
1176-
admin.site.register(BatchCommand, BatchCommandAdmin)
1177-
1178-
1179-
@admin.action(
1180-
description=_("Execute mass command"),
1181-
permissions=["execute_mass_command"],
1182-
)
1183-
def execute_mass_command(modeladmin, request, queryset):
1184-
"""Second entry point of the mass command workflow: the devices are
1185-
picked one by one instead of being matched by organization, group or
1186-
location. The selection travels in the form rather than in the session,
1187-
so it cannot outlive the wizard it belongs to.
1188-
"""
1189-
# TODO: replace _registry with get_model_admin once Django 4.2 is dropped
1190-
batch_admin = modeladmin.admin_site._registry[BatchCommand]
1191-
batch_admin._check_add_permission(request)
1192-
organization_ids = set(queryset.values_list("organization_id", flat=True))
1193-
if len(organization_ids) > 1:
1194-
modeladmin.message_user(
1195-
request,
1196-
_(
1197-
"All devices must belong to the same organization,"
1198-
" unless it is a system wide command."
1199-
),
1200-
messages.ERROR,
1187+
@staticmethod
1188+
@admin.action(description=_("Execute mass command"), permissions=["change"])
1189+
def execute_mass_command_admin_action(modeladmin, request, queryset):
1190+
"""Second entry point of the mass command workflow: the devices are
1191+
picked one by one instead of being matched by organization, group or
1192+
location. The selection travels in the form rather than in the session,
1193+
so it cannot outlive the wizard it belongs to.
1194+
"""
1195+
batch_admin = modeladmin.admin_site.get_model_admin(BatchCommand)
1196+
batch_admin._check_add_permission(request)
1197+
organization_ids = set(queryset.values_list("organization_id", flat=True))
1198+
if len(organization_ids) > 1:
1199+
if request.user.is_superuser and queryset.count() == Device.objects.count():
1200+
return batch_admin._render_execute_page(
1201+
request,
1202+
BatchCommandExecutionForm(request=request, system_wide=True),
1203+
)
1204+
modeladmin.message_user(
1205+
request,
1206+
_("All devices must belong to the same organization."),
1207+
messages.ERROR,
1208+
)
1209+
return HttpResponseRedirect(request.get_full_path())
1210+
form = BatchCommandExecutionForm(
1211+
request=request,
1212+
device_ids=[str(pk) for pk in queryset.values_list("pk", flat=True)],
12011213
)
1202-
return HttpResponseRedirect(request.get_full_path())
1203-
form = BatchCommandExecutionForm(
1204-
request=request,
1205-
device_ids=[str(pk) for pk in queryset.values_list("pk", flat=True)],
1206-
)
1207-
return batch_admin._render_execute_page(request, form)
1214+
return batch_admin._render_execute_page(request, form)
12081215

12091216

1210-
def has_execute_mass_command_permission(self, request):
1211-
options = BatchCommand._meta
1212-
return request.user.has_perm(f"{options.app_label}.add_{options.model_name}")
1213-
1214-
1215-
DeviceAdmin.execute_mass_command = execute_mass_command
1216-
DeviceAdmin.has_execute_mass_command_permission = has_execute_mass_command_permission
1217-
DeviceAdmin.actions += ["execute_mass_command"]
1217+
admin.site.register(BatchCommand, BatchCommandAdmin)
1218+
DeviceAdmin.actions += [BatchCommandAdmin.execute_mass_command_admin_action]

openwisp_controller/connection/static/connection/css/batch-command.css

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,6 @@
307307
.execute-batch-command .form-row.field-input.errors .flex-container {
308308
display: none;
309309
}
310-
.execute-batch-command ul.selected-devices-message li {
311-
padding: 15px 10px 15px 42px !important;
312-
background-position: 15px 17px !important;
313-
}
314310
#main .form-row .ow-text-field {
315311
width: 320px;
316312
}

openwisp_controller/connection/templates/admin/connection/batch_command/execute_command.html

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
{% block content %}
3434
<div id="content-main">
3535
{# the action renders this page from the device changelist, so the target is explicit #}
36-
<form method="post" novalidate action="{% url opts|admin_urlname:'execute' %}">
36+
<form method="post" novalidate class="execute-form" action="{% url opts|admin_urlname:'execute' %}">
3737
{% csrf_token %}
3838
{{ form.devices }}
3939
<nav class="stepper" aria-label="{% trans 'Execution progress' %}">
@@ -67,13 +67,6 @@
6767
the markup of the admin, so the page is styled like any other admin form
6868
{% endcomment %}
6969
{% for title, fields in form.fieldsets %}
70-
{% if forloop.last and device_count %}
71-
<ul class="messagelist selected-devices-message">
72-
<li class="warning">
73-
{% blocktrans count counter=device_count %}The command will run on the device you selected.{% plural %}The command will run on the {{ counter }} devices you selected.{% endblocktrans %}
74-
</li>
75-
</ul>
76-
{% endif %}
7770
<fieldset class="module aligned">
7871
<h2>{{ title }}</h2>
7972
{% for field in fields %}

openwisp_controller/connection/tests/test_admin.py

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,7 @@ def test_device_action_selection(self):
883883
self.assertNotContains(response, 'name="group"')
884884
self.assertNotContains(response, 'name="location"')
885885
with self.subTest("devices of different organizations are refused"):
886-
response = self._post_device_action(devices + [device_org2])
886+
response = self._post_device_action([devices[0], device_org2])
887887
self.assertRedirects(response, self.device_changelist_url)
888888
self.assertIn(
889889
"All devices must belong to the same organization",
@@ -904,6 +904,74 @@ def test_device_action_selection(self):
904904
self._post_confirm(wizard["token"])
905905
batch = BatchCommand.objects.get()
906906
self.assertEqual(set(batch.devices.all()), set(devices))
907+
with self.subTest("a single device is announced in the singular"):
908+
response = self._post_device_action(devices[:1])
909+
self.assertContains(
910+
response, "The command will run on the device you selected."
911+
)
912+
self._start_wizard(devices=self._pk_list(devices[:1]))
913+
response = self.client.get(self.confirm_url)
914+
self.assertEqual(response.context["targets_display"], "1 selected device")
915+
916+
def test_device_action_system_wide(self):
917+
org = self._get_org()
918+
org2 = self._create_org(name="org2", slug="org2")
919+
devices = [
920+
self._create_device(
921+
name=f"device{index}",
922+
mac_address=f"00:11:22:33:44:0{index}",
923+
organization=org,
924+
)
925+
for index in range(2)
926+
]
927+
device_org2 = self._create_device(
928+
name="device-org2", mac_address="00:11:22:33:44:09", organization=org2
929+
)
930+
self._login()
931+
with self.subTest("selecting every device is system wide"):
932+
response = self._post_device_action(
933+
devices + [device_org2], select_across=True
934+
)
935+
self.assertEqual(response.status_code, 200)
936+
form = response.context["form"]
937+
self.assertEqual(form.device_ids, [])
938+
self.assertEqual(response.context["device_count"], 0)
939+
self.assertIsNone(form.fields["organization"].initial)
940+
for field_name in ("organization", "group", "location"):
941+
self.assertFalse(form.fields[field_name].disabled)
942+
self.assertContains(response, "The command will run on all devices.")
943+
self.assertNotContains(response, 'name="organization"')
944+
self.assertNotContains(response, 'name="group"')
945+
self.assertNotContains(response, 'name="location"')
946+
with self.subTest("the excluded devices are left out of the batch"):
947+
wizard = self._start_wizard()
948+
self.client.get(self.confirm_url)
949+
response = self._post_confirm(wizard["token"], excluded=str(device_org2.pk))
950+
self.assertEqual(response.status_code, 302)
951+
self.assertIn(
952+
"Mass command executed successfully.", self._messages(response)
953+
)
954+
batch = BatchCommand.objects.get()
955+
self.assertIsNone(batch.organization_id)
956+
self.assertIsNone(batch.group_id)
957+
self.assertIsNone(batch.location_id)
958+
self.assertEqual(set(batch.devices.all()), set(devices))
959+
with self.subTest("a partial multi organization selection is refused"):
960+
self._create_device(
961+
name="excluded-by-the-search",
962+
mac_address="00:11:22:33:44:08",
963+
organization=org2,
964+
)
965+
response = self._post_device_action(
966+
devices + [device_org2],
967+
select_across=True,
968+
query={"q": "device"},
969+
)
970+
self.assertRedirects(response, f"{self.device_changelist_url}?q=device")
971+
self.assertIn(
972+
"All devices must belong to the same organization",
973+
" ".join(self._messages(response)),
974+
)
907975

908976
def test_device_action_permissions_and_scope(self):
909977
org = self._get_org()
@@ -912,9 +980,10 @@ def test_device_action_permissions_and_scope(self):
912980
device2 = self._create_device(
913981
name="device2", mac_address="00:11:22:33:44:02", organization=org2
914982
)
915-
device_admin = admin.site._registry[Device]
983+
device_admin = admin.site.get_model_admin(Device)
916984
request = RequestFactory().get(self.device_changelist_url)
917-
with self.subTest("the add permission is required"):
985+
action_name = "execute_mass_command_admin_action"
986+
with self.subTest("the device change permission is required"):
918987
viewer = self._create_operator(
919988
organizations=[org], username="viewer", email="viewer@test.com"
920989
)
@@ -923,13 +992,19 @@ def test_device_action_permissions_and_scope(self):
923992
Permission.objects.filter(codename="view_batchcommand")
924993
)
925994
request.user = viewer
926-
self.assertFalse(device_admin.has_execute_mass_command_permission(request))
927-
self.assertNotIn("execute_mass_command", device_admin.get_actions(request))
995+
self.assertNotIn(action_name, device_admin.get_actions(request))
928996
with self.subTest("the operator group can use the action"):
929997
operator = self._create_operator(organizations=[org])
930998
request.user = operator
931-
self.assertTrue(device_admin.has_execute_mass_command_permission(request))
932-
self.assertIn("execute_mass_command", device_admin.get_actions(request))
999+
self.assertIn(action_name, device_admin.get_actions(request))
1000+
with self.subTest("the batch command add permission is enforced"):
1001+
viewer.user_permissions.set(
1002+
Permission.objects.filter(
1003+
codename__in=["view_device", "change_device", "view_batchcommand"]
1004+
)
1005+
)
1006+
self.client.force_login(viewer)
1007+
self.assertEqual(self._post_device_action([device]).status_code, 403)
9331008
with self.subTest("devices of unmanaged organizations are dropped"):
9341009
self.client.force_login(operator)
9351010
response = self._post_execute(devices=self._pk_list([device, device2]))
@@ -951,6 +1026,28 @@ def test_device_action_permissions_and_scope(self):
9511026
"All devices must belong to the same organization",
9521027
" ".join(form.errors["__all__"]),
9531028
)
1029+
with self.subTest("selecting every device is not system wide for operators"):
1030+
multi_operator = self._create_operator(
1031+
organizations=[org, org2], username="multi", email="multi@test.com"
1032+
)
1033+
self.client.force_login(multi_operator)
1034+
response = self._post_device_action([device, device2], select_across=True)
1035+
self.assertRedirects(response, self.device_changelist_url)
1036+
self.assertIn(
1037+
"All devices must belong to the same organization",
1038+
" ".join(self._messages(response)),
1039+
)
1040+
with self.subTest("an operator executes the devices it manages"):
1041+
self.client.force_login(operator)
1042+
wizard = self._start_wizard(devices=self._pk_list([device]))
1043+
self.assertEqual(wizard["device_ids"], [str(device.pk)])
1044+
self.assertEqual(wizard["organization_id"], str(org.pk))
1045+
self.client.get(self.confirm_url)
1046+
response = self._post_confirm(wizard["token"])
1047+
self.assertEqual(response.status_code, 302)
1048+
batch = BatchCommand.objects.get()
1049+
self.assertEqual(batch.organization_id, org.pk)
1050+
self.assertEqual(set(batch.devices.all()), {device})
9541051

9551052
def test_changelist_multitenancy(self):
9561053
org = self._get_org()

0 commit comments

Comments
 (0)