Skip to content

Commit d1fd82d

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 08a1b77 commit d1fd82d

8 files changed

Lines changed: 354 additions & 80 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: 47 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,7 @@ def clean(self):
160160
)
161161
if len(self._organization_ids) > 1:
162162
raise ValidationError(
163-
_(
164-
"All devices must belong to the same organization,"
165-
" unless it is a system wide command."
166-
)
163+
_("All devices must belong to the same organization.")
167164
)
168165
if self.request is None or self.request.user.is_superuser:
169166
return cleaned_data
@@ -594,15 +591,29 @@ def execute_command_view(self, request):
594591
form = BatchCommandExecutionForm(request=request)
595592
return self._render_execute_page(request, form)
596593

597-
def _render_execute_page(self, request, form):
594+
def _render_execute_page(self, request, form, system_wide=False):
595+
device_count = len(form.device_ids)
596+
if device_count:
597+
messages.warning(
598+
request,
599+
ngettext(
600+
"The command will run on the device you selected.",
601+
"The command will run on the %(count)d devices you selected.",
602+
device_count,
603+
)
604+
% {"count": device_count},
605+
)
606+
elif system_wide:
607+
messages.warning(request, _("The command will run on all devices."))
598608
context = {
599609
**self.admin_site.each_context(request),
600610
"title": _("Execute mass command"),
601611
"opts": self.opts,
602612
"form": form,
603613
"media": form.media,
604614
"has_view_permission": self.has_view_permission(request),
605-
"device_count": len(form.device_ids),
615+
"device_count": device_count,
616+
"system_wide": system_wide,
606617
}
607618
return TemplateResponse(request, self.execute_command_template, context)
608619

@@ -1117,46 +1128,36 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
11171128
)
11181129
return super().change_view(request, object_id, extra_context=extra_context)
11191130

1120-
1121-
admin.site.register(BatchCommand, BatchCommandAdmin)
1122-
1123-
1124-
@admin.action(
1125-
description=_("Execute mass command"),
1126-
permissions=["execute_mass_command"],
1127-
)
1128-
def execute_mass_command(modeladmin, request, queryset):
1129-
"""Second entry point of the mass command workflow: the devices are
1130-
picked one by one instead of being matched by organization, group or
1131-
location. The selection travels in the form rather than in the session,
1132-
so it cannot outlive the wizard it belongs to.
1133-
"""
1134-
# TODO: replace _registry with get_model_admin once Django 4.2 is dropped
1135-
batch_admin = modeladmin.admin_site._registry[BatchCommand]
1136-
batch_admin._check_add_permission(request)
1137-
organization_ids = set(queryset.values_list("organization_id", flat=True))
1138-
if len(organization_ids) > 1:
1139-
modeladmin.message_user(
1140-
request,
1141-
_(
1142-
"All devices must belong to the same organization,"
1143-
" unless it is a system wide command."
1144-
),
1145-
messages.ERROR,
1131+
@staticmethod
1132+
@admin.action(description=_("Execute mass command"), permissions=["change"])
1133+
def execute_mass_command_admin_action(modeladmin, request, queryset):
1134+
"""Second entry point of the mass command workflow: the devices are
1135+
picked one by one instead of being matched by organization, group or
1136+
location. The selection travels in the form rather than in the session,
1137+
so it cannot outlive the wizard it belongs to.
1138+
"""
1139+
batch_admin = modeladmin.admin_site.get_model_admin(BatchCommand)
1140+
batch_admin._check_add_permission(request)
1141+
organization_ids = set(queryset.values_list("organization_id", flat=True))
1142+
if len(organization_ids) > 1:
1143+
if request.user.is_superuser and queryset.count() == Device.objects.count():
1144+
return batch_admin._render_execute_page(
1145+
request,
1146+
BatchCommandExecutionForm(request=request),
1147+
system_wide=True,
1148+
)
1149+
modeladmin.message_user(
1150+
request,
1151+
_("All devices must belong to the same organization."),
1152+
messages.ERROR,
1153+
)
1154+
return HttpResponseRedirect(request.get_full_path())
1155+
form = BatchCommandExecutionForm(
1156+
request=request,
1157+
device_ids=[str(pk) for pk in queryset.values_list("pk", flat=True)],
11461158
)
1147-
return HttpResponseRedirect(request.get_full_path())
1148-
form = BatchCommandExecutionForm(
1149-
request=request,
1150-
device_ids=[str(pk) for pk in queryset.values_list("pk", flat=True)],
1151-
)
1152-
return batch_admin._render_execute_page(request, form)
1153-
1159+
return batch_admin._render_execute_page(request, form)
11541160

1155-
def has_execute_mass_command_permission(self, request):
1156-
options = BatchCommand._meta
1157-
return request.user.has_perm(f"{options.app_label}.add_{options.model_name}")
11581161

1159-
1160-
DeviceAdmin.execute_mass_command = execute_mass_command
1161-
DeviceAdmin.has_execute_mass_command_permission = has_execute_mass_command_permission
1162-
DeviceAdmin.actions += ["execute_mass_command"]
1162+
admin.site.register(BatchCommand, BatchCommandAdmin)
1163+
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: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,7 @@ <h2>{% trans "Command" %}</h2>
6666
{% include "admin/connection/batch_command/form_row.html" with field=form.notes %}
6767
</fieldset>
6868

69-
{% if device_count %}
70-
<ul class="messagelist selected-devices-message">
71-
<li class="warning">
72-
{% 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 %}
73-
</li>
74-
</ul>
75-
{% endif %}
76-
69+
{% if not system_wide %}
7770
<fieldset class="module aligned">
7871
<h2>{% trans "Targets" %}</h2>
7972
{% include "admin/connection/batch_command/form_row.html" with field=form.organization %}
@@ -82,6 +75,7 @@ <h2>{% trans "Targets" %}</h2>
8275
{% include "admin/connection/batch_command/form_row.html" with field=form.group %}
8376
{% endif %}
8477
</fieldset>
78+
{% endif %}
8579

8680
<div class="submit-row">
8781
<a class="button cancel-link" href="{% url opts|admin_urlname:'changelist' %}">{% trans "Cancel" %}</a>

openwisp_controller/connection/tests/test_admin.py

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,7 @@ def test_device_action_selection(self):
856856
self.assertNotContains(response, 'name="group"')
857857
self.assertNotContains(response, 'name="location"')
858858
with self.subTest("devices of different organizations are refused"):
859-
response = self._post_device_action(devices + [device_org2])
859+
response = self._post_device_action([devices[0], device_org2])
860860
self.assertRedirects(response, self.device_changelist_url)
861861
self.assertIn(
862862
"All devices must belong to the same organization",
@@ -877,6 +877,74 @@ def test_device_action_selection(self):
877877
self._post_confirm(wizard["token"])
878878
batch = BatchCommand.objects.get()
879879
self.assertEqual(set(batch.devices.all()), set(devices))
880+
with self.subTest("a single device is announced in the singular"):
881+
response = self._post_device_action(devices[:1])
882+
self.assertContains(
883+
response, "The command will run on the device you selected."
884+
)
885+
self._start_wizard(devices=self._pk_list(devices[:1]))
886+
response = self.client.get(self.confirm_url)
887+
self.assertEqual(response.context["targets_display"], "1 selected device")
888+
889+
def test_device_action_system_wide(self):
890+
org = self._get_org()
891+
org2 = self._create_org(name="org2", slug="org2")
892+
devices = [
893+
self._create_device(
894+
name=f"device{index}",
895+
mac_address=f"00:11:22:33:44:0{index}",
896+
organization=org,
897+
)
898+
for index in range(2)
899+
]
900+
device_org2 = self._create_device(
901+
name="device-org2", mac_address="00:11:22:33:44:09", organization=org2
902+
)
903+
self._login()
904+
with self.subTest("selecting every device is system wide"):
905+
response = self._post_device_action(
906+
devices + [device_org2], select_across=True
907+
)
908+
self.assertEqual(response.status_code, 200)
909+
form = response.context["form"]
910+
self.assertEqual(form.device_ids, [])
911+
self.assertEqual(response.context["device_count"], 0)
912+
self.assertIsNone(form.fields["organization"].initial)
913+
for field_name in ("organization", "group", "location"):
914+
self.assertFalse(form.fields[field_name].disabled)
915+
self.assertContains(response, "The command will run on all devices.")
916+
self.assertNotContains(response, 'name="organization"')
917+
self.assertNotContains(response, 'name="group"')
918+
self.assertNotContains(response, 'name="location"')
919+
with self.subTest("the excluded devices are left out of the batch"):
920+
wizard = self._start_wizard()
921+
self.client.get(self.confirm_url)
922+
response = self._post_confirm(wizard["token"], excluded=str(device_org2.pk))
923+
self.assertEqual(response.status_code, 302)
924+
self.assertIn(
925+
"Mass command executed successfully.", self._messages(response)
926+
)
927+
batch = BatchCommand.objects.get()
928+
self.assertIsNone(batch.organization_id)
929+
self.assertIsNone(batch.group_id)
930+
self.assertIsNone(batch.location_id)
931+
self.assertEqual(set(batch.devices.all()), set(devices))
932+
with self.subTest("a partial multi organization selection is refused"):
933+
self._create_device(
934+
name="excluded-by-the-search",
935+
mac_address="00:11:22:33:44:08",
936+
organization=org2,
937+
)
938+
response = self._post_device_action(
939+
devices + [device_org2],
940+
select_across=True,
941+
query={"q": "device"},
942+
)
943+
self.assertRedirects(response, f"{self.device_changelist_url}?q=device")
944+
self.assertIn(
945+
"All devices must belong to the same organization",
946+
" ".join(self._messages(response)),
947+
)
880948

881949
def test_device_action_permissions_and_scope(self):
882950
org = self._get_org()
@@ -885,9 +953,10 @@ def test_device_action_permissions_and_scope(self):
885953
device2 = self._create_device(
886954
name="device2", mac_address="00:11:22:33:44:02", organization=org2
887955
)
888-
device_admin = admin.site._registry[Device]
956+
device_admin = admin.site.get_model_admin(Device)
889957
request = RequestFactory().get(self.device_changelist_url)
890-
with self.subTest("the add permission is required"):
958+
action_name = "execute_mass_command_admin_action"
959+
with self.subTest("the device change permission is required"):
891960
viewer = self._create_operator(
892961
organizations=[org], username="viewer", email="viewer@test.com"
893962
)
@@ -896,13 +965,19 @@ def test_device_action_permissions_and_scope(self):
896965
Permission.objects.filter(codename="view_batchcommand")
897966
)
898967
request.user = viewer
899-
self.assertFalse(device_admin.has_execute_mass_command_permission(request))
900-
self.assertNotIn("execute_mass_command", device_admin.get_actions(request))
968+
self.assertNotIn(action_name, device_admin.get_actions(request))
901969
with self.subTest("the operator group can use the action"):
902970
operator = self._create_operator(organizations=[org])
903971
request.user = operator
904-
self.assertTrue(device_admin.has_execute_mass_command_permission(request))
905-
self.assertIn("execute_mass_command", device_admin.get_actions(request))
972+
self.assertIn(action_name, device_admin.get_actions(request))
973+
with self.subTest("the batch command add permission is enforced"):
974+
viewer.user_permissions.set(
975+
Permission.objects.filter(
976+
codename__in=["view_device", "change_device", "view_batchcommand"]
977+
)
978+
)
979+
self.client.force_login(viewer)
980+
self.assertEqual(self._post_device_action([device]).status_code, 403)
906981
with self.subTest("devices of unmanaged organizations are dropped"):
907982
self.client.force_login(operator)
908983
response = self._post_execute(devices=self._pk_list([device, device2]))
@@ -924,6 +999,28 @@ def test_device_action_permissions_and_scope(self):
924999
"All devices must belong to the same organization",
9251000
" ".join(form.errors["__all__"]),
9261001
)
1002+
with self.subTest("selecting every device is not system wide for operators"):
1003+
multi_operator = self._create_operator(
1004+
organizations=[org, org2], username="multi", email="multi@test.com"
1005+
)
1006+
self.client.force_login(multi_operator)
1007+
response = self._post_device_action([device, device2], select_across=True)
1008+
self.assertRedirects(response, self.device_changelist_url)
1009+
self.assertIn(
1010+
"All devices must belong to the same organization",
1011+
" ".join(self._messages(response)),
1012+
)
1013+
with self.subTest("an operator executes the devices it manages"):
1014+
self.client.force_login(operator)
1015+
wizard = self._start_wizard(devices=self._pk_list([device]))
1016+
self.assertEqual(wizard["device_ids"], [str(device.pk)])
1017+
self.assertEqual(wizard["organization_id"], str(org.pk))
1018+
self.client.get(self.confirm_url)
1019+
response = self._post_confirm(wizard["token"])
1020+
self.assertEqual(response.status_code, 302)
1021+
batch = BatchCommand.objects.get()
1022+
self.assertEqual(batch.organization_id, org.pk)
1023+
self.assertEqual(set(batch.devices.all()), {device})
9271024

9281025
def test_changelist_multitenancy(self):
9291026
org = self._get_org()

0 commit comments

Comments
 (0)