Skip to content

Commit 687c9a9

Browse files
committed
[fix] Address comments
1 parent c45a51f commit 687c9a9

3 files changed

Lines changed: 95 additions & 13 deletions

File tree

openwisp_controller/connection/admin.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ def __init__(
143143
self.fields["type"].choices = empty_choices + list(allowed_commands.items())
144144

145145
def _scope_devices(self, device_ids):
146+
"""Keeps only the selected devices that exist and that the user can use.
147+
148+
Returns the ids which are left. It also notes which organizations
149+
those devices belong to, and whether any selected device was dropped
150+
because it was deleted or the user does not manage its organization,
151+
so that clean() can reject the form in those cases.
152+
"""
146153
self._organization_ids = set()
147154
self._dropped_devices = False
148155
if not device_ids:
@@ -158,6 +165,14 @@ def _scope_devices(self, device_ids):
158165
return [str(row[0]) for row in rows]
159166

160167
def _selected_organization_id(self):
168+
"""Returns the organization of the selected devices, or None.
169+
170+
When devices are picked from the device list, the organization field
171+
is locked and filled in with their organization, so the user can see
172+
where the command will run. This works only when all the devices
173+
belong to one organization: if they belong to several, None is
174+
returned and clean() rejects the form.
175+
"""
161176
if len(self._organization_ids) != 1:
162177
return None
163178
return str(next(iter(self._organization_ids)))
@@ -610,7 +625,9 @@ def execute_command_view(self, request):
610625
wizard = request.session.get(self.session_key)
611626
if request.GET.get("back") and wizard:
612627
form = BatchCommandExecutionForm(
613-
initial=self._wizard_initial(wizard), request=request
628+
initial=self._wizard_initial(wizard),
629+
request=request,
630+
device_ids=wizard.get("device_ids"),
614631
)
615632
else:
616633
# the wizard is left in the session: another tab may be
@@ -1194,9 +1211,12 @@ def execute_mass_command_admin_action(modeladmin, request, queryset):
11941211
"""
11951212
batch_admin = modeladmin.admin_site.get_model_admin(BatchCommand)
11961213
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():
1214+
organizations = queryset.order_by().values_list("organization_id", flat=True)
1215+
if organizations.distinct().count() > 1:
1216+
if (
1217+
request.user.is_superuser
1218+
and not Device.objects.exclude(pk__in=queryset.values("pk")).exists()
1219+
):
12001220
return batch_admin._render_execute_page(
12011221
request,
12021222
BatchCommandExecutionForm(request=request, system_wide=True),

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@
3232

3333
{% block content %}
3434
<div id="content-main">
35-
{# the action renders this page from the device changelist, so the target is explicit #}
35+
{% comment %}
36+
this page can be opened in two ways: from the menu, or from the "Execute mass
37+
command" action of the device list. The action shows this page without a
38+
redirect, so the URL in the browser stays on the device list. A form without
39+
an "action" attribute posts to the URL in the browser, which would send the
40+
data to the device list. Setting "action" makes the form always post to the
41+
execute page.
42+
{% endcomment %}
3643
<form method="post" novalidate class="execute-form" action="{% url opts|admin_urlname:'execute' %}">
3744
{% csrf_token %}
3845
{{ form.devices }}

openwisp_controller/connection/tests/test_admin.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,7 @@ def test_device_action_selection(self):
863863
name="device-org2", mac_address="00:11:22:33:44:09", organization=org2
864864
)
865865
self._login()
866+
866867
with self.subTest("the selection prefills the form"):
867868
response = self._post_device_action(devices)
868869
self.assertEqual(response.status_code, 200)
@@ -875,20 +876,23 @@ def test_device_action_selection(self):
875876
self.assertEqual(form.fields["organization"].initial, str(org.pk))
876877
for field_name in ("organization", "group", "location"):
877878
self.assertTrue(form.fields[field_name].disabled)
879+
878880
with self.subTest("the selection is announced and the wider targets hidden"):
879881
self.assertContains(
880882
response, "The command will run on the 2 devices you selected."
881883
)
882884
self.assertContains(response, 'name="devices"')
883885
self.assertNotContains(response, 'name="group"')
884886
self.assertNotContains(response, 'name="location"')
887+
885888
with self.subTest("devices of different organizations are refused"):
886889
response = self._post_device_action([devices[0], device_org2])
887890
self.assertRedirects(response, self.device_changelist_url)
888891
self.assertIn(
889892
"All devices must belong to the same organization",
890893
" ".join(self._messages(response)),
891894
)
895+
892896
with self.subTest("the selection travels to the review step"):
893897
response = self._post_execute(devices=self._pk_list(devices))
894898
self.assertEqual(response.status_code, 302)
@@ -900,23 +904,52 @@ def test_device_action_selection(self):
900904
self.assertEqual(response.context["device_count"], 2)
901905
self.assertEqual(response.context["targets_display"], "2 selected devices")
902906
self.assertEqual(set(response.context["cl"].queryset), set(devices))
907+
903908
with self.subTest("only the selected devices are executed"):
904909
self._post_confirm(wizard["token"])
905910
batch = BatchCommand.objects.get()
906911
self.assertEqual(set(batch.devices.all()), set(devices))
907-
with self.subTest("a single device is announced in the singular"):
912+
913+
with self.subTest("the texts use the singular when one device is selected"):
908914
response = self._post_device_action(devices[:1])
909915
self.assertContains(
910916
response, "The command will run on the device you selected."
911917
)
918+
self.assertNotContains(response, "devices you selected")
912919
self._start_wizard(devices=self._pk_list(devices[:1]))
913920
response = self.client.get(self.confirm_url)
914921
self.assertEqual(response.context["targets_display"], "1 selected device")
915922

923+
with self.subTest("the selection is kept when going back to edit"):
924+
self._create_device(
925+
name="device-not-selected",
926+
mac_address="00:11:22:33:44:08",
927+
organization=org,
928+
)
929+
selected = {str(device.pk) for device in devices}
930+
self._start_wizard(devices=self._pk_list(devices))
931+
self.client.get(self.confirm_url)
932+
response = self.client.get(f"{self.execute_url}?back=1")
933+
form = response.context["form"]
934+
self.assertEqual(set(form.device_ids), selected)
935+
for field_name in ("organization", "group", "location"):
936+
self.assertTrue(form.fields[field_name].disabled)
937+
self.assertContains(
938+
response, "The command will run on the 2 devices you selected."
939+
)
940+
self._start_wizard(
941+
devices=form.fields["devices"].initial or "", label="edited-label"
942+
)
943+
wizard = self.client.session[BatchCommandAdmin.session_key]
944+
self.assertEqual(wizard["label"], "edited-label")
945+
self.assertEqual(set(wizard["device_ids"]), selected)
946+
response = self.client.get(self.confirm_url)
947+
self.assertEqual(set(response.context["cl"].queryset), set(devices))
948+
916949
def test_device_action_system_wide(self):
917950
org = self._get_org()
918951
org2 = self._create_org(name="org2", slug="org2")
919-
devices = [
952+
org1_devices = [
920953
self._create_device(
921954
name=f"device{index}",
922955
mac_address=f"00:11:22:33:44:0{index}",
@@ -928,10 +961,10 @@ def test_device_action_system_wide(self):
928961
name="device-org2", mac_address="00:11:22:33:44:09", organization=org2
929962
)
930963
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-
)
964+
965+
with self.subTest("selecting every device runs the command on all of them"):
966+
all_devices = org1_devices + [device_org2]
967+
response = self._post_device_action(all_devices, select_across=True)
935968
self.assertEqual(response.status_code, 200)
936969
form = response.context["form"]
937970
self.assertEqual(form.device_ids, [])
@@ -943,6 +976,19 @@ def test_device_action_system_wide(self):
943976
self.assertNotContains(response, 'name="organization"')
944977
self.assertNotContains(response, 'name="group"')
945978
self.assertNotContains(response, 'name="location"')
979+
wizard = self._start_wizard()
980+
response = self.client.get(self.confirm_url)
981+
self.assertEqual(response.context["targets_display"], "All devices")
982+
self.assertEqual(set(response.context["cl"].queryset), set(all_devices))
983+
response = self._post_confirm(wizard["token"])
984+
self.assertIn(
985+
"Mass command executed successfully.", self._messages(response)
986+
)
987+
batch = BatchCommand.objects.get()
988+
self.assertIsNone(batch.organization_id)
989+
self.assertEqual(set(batch.devices.all()), set(all_devices))
990+
BatchCommand.objects.all().delete()
991+
946992
with self.subTest("the excluded devices are left out of the batch"):
947993
wizard = self._start_wizard()
948994
self.client.get(self.confirm_url)
@@ -955,15 +1001,16 @@ def test_device_action_system_wide(self):
9551001
self.assertIsNone(batch.organization_id)
9561002
self.assertIsNone(batch.group_id)
9571003
self.assertIsNone(batch.location_id)
958-
self.assertEqual(set(batch.devices.all()), set(devices))
1004+
self.assertEqual(set(batch.devices.all()), set(org1_devices))
1005+
9591006
with self.subTest("a partial multi organization selection is refused"):
9601007
self._create_device(
9611008
name="excluded-by-the-search",
9621009
mac_address="00:11:22:33:44:08",
9631010
organization=org2,
9641011
)
9651012
response = self._post_device_action(
966-
devices + [device_org2],
1013+
org1_devices + [device_org2],
9671014
select_across=True,
9681015
query={"q": "device"},
9691016
)
@@ -983,6 +1030,7 @@ def test_device_action_permissions_and_scope(self):
9831030
device_admin = admin.site.get_model_admin(Device)
9841031
request = RequestFactory().get(self.device_changelist_url)
9851032
action_name = "execute_mass_command_admin_action"
1033+
9861034
with self.subTest("the device change permission is required"):
9871035
viewer = self._create_operator(
9881036
organizations=[org], username="viewer", email="viewer@test.com"
@@ -993,10 +1041,12 @@ def test_device_action_permissions_and_scope(self):
9931041
)
9941042
request.user = viewer
9951043
self.assertNotIn(action_name, device_admin.get_actions(request))
1044+
9961045
with self.subTest("the operator group can use the action"):
9971046
operator = self._create_operator(organizations=[org])
9981047
request.user = operator
9991048
self.assertIn(action_name, device_admin.get_actions(request))
1049+
10001050
with self.subTest("the batch command add permission is enforced"):
10011051
viewer.user_permissions.set(
10021052
Permission.objects.filter(
@@ -1005,6 +1055,7 @@ def test_device_action_permissions_and_scope(self):
10051055
)
10061056
self.client.force_login(viewer)
10071057
self.assertEqual(self._post_device_action([device]).status_code, 403)
1058+
10081059
with self.subTest("devices of unmanaged organizations are dropped"):
10091060
self.client.force_login(operator)
10101061
response = self._post_execute(devices=self._pk_list([device, device2]))
@@ -1014,10 +1065,12 @@ def test_device_action_permissions_and_scope(self):
10141065
"Some of the selected devices are no longer available.",
10151066
form.errors["__all__"],
10161067
)
1068+
10171069
with self.subTest("devices which disappeared are dropped"):
10181070
self._login()
10191071
response = self._post_execute(devices=f"{device.pk},{uuid4()}")
10201072
self.assertEqual(response.context["form"].device_ids, [str(device.pk)])
1073+
10211074
with self.subTest("mixed organizations are refused by the form"):
10221075
response = self._post_execute(devices=self._pk_list([device, device2]))
10231076
form = response.context["form"]
@@ -1026,6 +1079,7 @@ def test_device_action_permissions_and_scope(self):
10261079
"All devices must belong to the same organization",
10271080
" ".join(form.errors["__all__"]),
10281081
)
1082+
10291083
with self.subTest("selecting every device is not system wide for operators"):
10301084
multi_operator = self._create_operator(
10311085
organizations=[org, org2], username="multi", email="multi@test.com"
@@ -1037,6 +1091,7 @@ def test_device_action_permissions_and_scope(self):
10371091
"All devices must belong to the same organization",
10381092
" ".join(self._messages(response)),
10391093
)
1094+
10401095
with self.subTest("an operator executes the devices it manages"):
10411096
self.client.force_login(operator)
10421097
wizard = self._start_wizard(devices=self._pk_list([device]))

0 commit comments

Comments
 (0)