Skip to content

Commit 7abf4a8

Browse files
committed
[fix] CI and a bug in admin detail page
- Restored the stderr capture in the two command task tests which still used redirect_stderr after its import was removed, fixing the failing test suite - Built the filters of the detail page from the organization of the batch instead of its command rows, so that they are rendered right after the execution instead of appearing only once the worker created the commands - Hid the location and the device group filter when the batch was targeted on one of them - Formatted the initial and the live timestamps through one locale aware path, so that a row does not change format after it is updated
1 parent 8c00a9c commit 7abf4a8

13 files changed

Lines changed: 172 additions & 73 deletions

File tree

docs/user/websocket-api.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,10 @@ When the mass command itself changes, for example when it moves from
220220
221221
The status and the timestamps are sent as they are stored, without
222222
translation or formatting, so that each client can render them with its
223-
own language and time zone.
223+
own language and time zone. Command rows carry ``modified_display`` as
224+
well, which is the same timestamp already formatted with the locale and
225+
the time zone of the server: the admin uses it so that a row updated over
226+
the websocket reads exactly like the rows rendered with the page.
224227

225228
When the command of one device changes:
226229

@@ -237,6 +240,8 @@ When the command of one device changes:
237240
"output": "<string>", // Output collected so far
238241
"created": "<string>", // ISO 8601 timestamp
239242
"modified": "<string>", // ISO 8601 timestamp
243+
"modified_display": "<string>", // Modified, formatted by the server with its
244+
// own locale and time zone
240245
"index": <integer>, // Position of the row, sent only for new commands
241246
"affected_devices": <integer>, // Commands created so far, sent with "index"
242247
"total_rows": <integer> // Affected plus skipped devices, sent with "index"

openwisp_controller/connection/admin.py

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from django.contrib import admin, messages
1111
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
1212
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
13-
from django.db.models import Count, Q
13+
from django.db.models import Count
1414
from django.http import HttpResponseForbidden, HttpResponseNotAllowed, JsonResponse
1515
from django.shortcuts import redirect
1616
from django.template.response import TemplateResponse
@@ -28,6 +28,7 @@
2828
from .commands import ORGANIZATION_COMMAND_SCHEMA
2929
from .filters import GroupFilter, LocationFilter, TypeFilter
3030
from .schema import schema
31+
from .utils import format_modified
3132
from .widgets import (
3233
BatchCommandSchemaWidget,
3334
CommandSchemaWidget,
@@ -895,52 +896,52 @@ def _make_choice(current_value, display, param_name, value):
895896

896897
filter_specs.append(SimpleNamespace(title=_("status"), choices=status_choices))
897898

898-
batch_devices = Device.objects.filter(
899-
Q(command__batch_command=obj) | Q(pk__in=obj.skipped_device_ids)
900-
)
899+
locations = Location.objects.all()
900+
groups = DeviceGroup.objects.all()
901+
if obj.organization_id:
902+
locations = locations.filter(organization_id=obj.organization_id)
903+
groups = groups.filter(organization_id=obj.organization_id)
901904
if not request.user.is_superuser:
902-
batch_devices = batch_devices.filter(
905+
locations = locations.filter(
906+
organization_id__in=request.user.organizations_managed
907+
)
908+
groups = groups.filter(
903909
organization_id__in=request.user.organizations_managed
904910
)
905911

906912
# Location filter
907-
location_spec = self._build_related_filter(
908-
_("location"),
909-
"location_id",
910-
current_location or "",
911-
batch_devices.exclude(devicelocation__location__isnull=True)
912-
.values_list(
913-
"devicelocation__location__id",
914-
"devicelocation__location__name",
913+
location_spec = None
914+
if not obj.location_id:
915+
location_spec = self._build_related_filter(
916+
_("location"),
917+
"location_id",
918+
current_location or "",
919+
locations.values_list("id", "name"),
920+
_make_choice,
915921
)
916-
.distinct(),
917-
_make_choice,
918-
)
919922
if location_spec:
920923
filter_specs.append(location_spec)
921924

922925
# Group filter
923-
group_spec = self._build_related_filter(
924-
_("device group"),
925-
"group_id",
926-
current_group or "",
927-
batch_devices.filter(group__isnull=False)
928-
.values_list("group__id", "group__name")
929-
.distinct(),
930-
_make_choice,
931-
)
926+
group_spec = None
927+
if not obj.group_id:
928+
group_spec = self._build_related_filter(
929+
_("device group"),
930+
"group_id",
931+
current_group or "",
932+
groups.values_list("id", "name"),
933+
_make_choice,
934+
)
932935
if group_spec:
933936
filter_specs.append(group_spec)
934937

935-
# Organization filter (superusers only)
936-
if request.user.is_superuser:
938+
# Organization filter (system wide batches only, superusers only)
939+
if request.user.is_superuser and not obj.organization_id:
937940
org_spec = self._build_related_filter(
938941
_("organization"),
939942
"organization_id",
940943
current_org or "",
941-
batch_devices.values_list(
942-
"organization__id", "organization__name"
943-
).distinct(),
944+
Organization.objects.values_list("id", "name"),
944945
_make_choice,
945946
)
946947
if org_spec:
@@ -967,7 +968,7 @@ def _command_row(command):
967968
"status": command.status,
968969
"status_display": command.get_status_display(),
969970
"output": command.output_preview,
970-
"modified": command.modified,
971+
"modified_display": format_modified(command.modified),
971972
"is_skipped": False,
972973
}
973974

openwisp_controller/connection/base/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,7 @@ def build_skipped_row(device_pk, skipped):
855855
"status_display": gettext("skipped"),
856856
"output": skipped["error"],
857857
"modified": None,
858+
"modified_display": "",
858859
"is_skipped": True,
859860
}
860861

openwisp_controller/connection/channels/consumers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ...config.base.channels_consumer import BaseDeviceConsumer
99
from ..api.serializers import BatchCommandSerializer, CommandSerializer
10+
from ..utils import format_modified
1011

1112
logger = logging.getLogger(__name__)
1213

@@ -126,6 +127,7 @@ def _handle_current_state_request(self, page=None, filters=None):
126127
row.pop("input", None)
127128
row["device_name"] = command.device.name
128129
row["output"] = command.output_preview
130+
row["modified_display"] = format_modified(command.modified)
129131
commands.append(row)
130132
commands += batch.get_skipped_rows(
131133
max(0, start - commands_count),

openwisp_controller/connection/handlers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from django.dispatch import receiver
88
from swapper import load_model
99

10+
from .utils import format_modified
11+
1012
logger = logging.getLogger(__name__)
1113

1214
Command = load_model("connection", "Command")
@@ -44,6 +46,7 @@ def command_save_handler(sender, created, instance, **kwargs):
4446
batch_data.pop("input", None)
4547
batch_data["device_name"] = instance.device.name
4648
batch_data["output"] = instance.output_preview
49+
batch_data["modified_display"] = format_modified(instance.modified)
4750
batch_data["type"] = "command_update"
4851
if created:
4952
batch = instance.batch_command

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

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,6 @@ function getStatusLabel(status) {
9494
return labels[status] || status;
9595
}
9696

97-
function getFormattedDateTimeString(dateTimeString) {
98-
if (!dateTimeString) {
99-
return "-";
100-
}
101-
const formattedString = new Date(dateTimeString).strftime("%B %d, %Y %I:%M %p"),
102-
stringArray = formattedString.split(" ");
103-
stringArray[0] = stringArray[0].substring(0, 4) + ".";
104-
stringArray[4] = stringArray[4] == "AM" ? "a.m." : "p.m.";
105-
return stringArray.join(" ");
106-
}
107-
10897
function handleBatchStatusMessage($, data, websocket) {
10998
const $status = $(".field-colored_status .readonly .command-status");
11099
if ($status.length && data.status) {
@@ -258,7 +247,7 @@ function updateRow($, $row, data) {
258247
.addClass("command-status " + data.status)
259248
.text(getStatusLabel(data.status));
260249
$row.find(".command-output pre").text(data.output || "-");
261-
$row.find("td:last-child").text(getFormattedDateTimeString(data.modified));
250+
$row.find("td:last-child").text(data.modified_display || "-");
262251
}
263252

264253
function insertRow($, data) {
@@ -300,7 +289,7 @@ function insertRow($, data) {
300289
.addClass("command-output")
301290
.append($("<pre>").text(data.output || "-")),
302291
);
303-
$row.append($("<td>").text(getFormattedDateTimeString(data.modified)));
292+
$row.append($("<td>").text(data.modified_display || "-"));
304293
$tableBody.append($row);
305294
}
306295

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ <h3 id="changelist-filter-clear" class="filter-clear-heading">
137137
<td class="command-output">
138138
<pre>{{ command.output|default:"-" }}</pre>
139139
</td>
140-
<td>{{ command.modified|date:"DATETIME_FORMAT"|default:"-" }}</td>
140+
<td>{{ command.modified_display|default:"-" }}</td>
141141
</tr>
142142
{% empty %}
143143
<tr>

openwisp_controller/connection/tests/pytest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from .. import handlers
1717
from ..channels.consumers import BatchCommandConsumer
18+
from ..utils import format_modified
1819
from .test_models import BaseTestModels
1920

2021
User = get_user_model()
@@ -264,6 +265,7 @@ async def test_batch_command_consumer_current_state(
264265
command_row["modified"]
265266
== timezone.localtime(command.modified).isoformat()
266267
)
268+
assert command_row["modified_display"] == format_modified(command.modified)
267269
assert "input" not in command_row
268270
await communicator.send_json_to(
269271
{"type": "request_current_state", "page": 2}
@@ -309,7 +311,14 @@ async def test_batch_command_consumer_current_state(
309311
{"type": "request_current_state", "page": page}
310312
)
311313
response = await communicator.receive_json_from()
314+
assert response["page"] == 1
312315
assert response["commands"] == page1["commands"]
316+
await communicator.send_json_to(
317+
{"type": "request_current_state", "page": 99}
318+
)
319+
clamped = await communicator.receive_json_from()
320+
assert clamped["page"] == 2
321+
assert clamped["commands"] == page2["commands"]
313322
await communicator.disconnect()
314323
communicator, connected = await self._connect(batch.pk, admin_user)
315324
assert connected is True

openwisp_controller/connection/tests/test_admin.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ..admin import BatchCommandAdmin, BatchCommandExecutionForm
2525
from ..connectors.ssh import Ssh
2626
from ..filters import GroupFilter, LocationFilter, TypeFilter
27+
from ..utils import format_modified
2728
from ..widgets import CredentialsSchemaWidget
2829
from .utils import BatchCommandMixin, CreateConnectionsMixin
2930

@@ -969,6 +970,28 @@ def test_change_view_command_rows(self):
969970
self.assertEqual(rows[0]["output"], "… last")
970971
self.assertEqual(rows[0]["status_display"], "in progress")
971972
self.assertFalse(rows[0]["is_skipped"])
973+
self.assertEqual(
974+
rows[0]["modified_display"],
975+
format_modified(commands[0].modified),
976+
)
977+
978+
with self.subTest("the rows follow the locale and the time zone"):
979+
default = self.client.get(url).context["commands"][0]
980+
with override_settings(
981+
LANGUAGE_CODE="it", TIME_ZONE="Pacific/Auckland"
982+
):
983+
localized = self.client.get(url).context["commands"][0]
984+
self.assertEqual(
985+
localized["modified_display"],
986+
format_modified(commands[0].modified),
987+
)
988+
self.assertNotEqual(
989+
localized["modified_display"], default["modified_display"]
990+
)
991+
992+
with self.subTest("skipped devices have no timestamp"):
993+
rows = self.client.get(url, {"page": 3}).context["commands"]
994+
self.assertEqual([row["modified_display"] for row in rows], [""] * 3)
972995

973996
with self.subTest("the page spanning commands and skipped devices"):
974997
rows = self.client.get(url, {"page": 2}).context["commands"]
@@ -1025,7 +1048,7 @@ def test_change_view_filters(self):
10251048
)
10261049
device = self._create_device(organization=org, group=group)
10271050
DeviceLocation.objects.create(content_object=device, location=location)
1028-
batch = self._create_batch_command(organization=org, group=group)
1051+
batch = self._create_batch_command(organization=org)
10291052
other_group = DeviceGroup.objects.create(name="skipped-group", organization=org)
10301053
skipped_device = self._create_device(
10311054
name="skipped-device",
@@ -1133,20 +1156,58 @@ def test_change_view_filters(self):
11331156
titles = [str(spec.title) for spec in response.context["filter_specs"]]
11341157
self.assertNotIn("organization", titles)
11351158

1136-
with self.subTest("the filters do not offer other organizations"):
1137-
specs = {
1159+
def _filter_specs(target):
1160+
return {
11381161
str(spec.title): [str(choice["display"]) for choice in spec.choices]
1139-
for spec in self.client.get(url).context["filter_specs"]
1162+
for spec in self.client.get(target).context["filter_specs"]
11401163
}
1141-
self.assertNotIn(transferred_group.name, specs["device group"])
1142-
self.assertIn(other_group.name, specs["device group"])
1143-
self.assertNotIn(transferred_location.name, specs["location"])
1164+
1165+
with self.subTest("the filters do not offer other organizations"):
1166+
for login in (lambda: self.client.force_login(operator), self._login):
1167+
login()
1168+
specs = _filter_specs(url)
1169+
self.assertNotIn(transferred_group.name, specs["device group"])
1170+
self.assertIn(other_group.name, specs["device group"])
1171+
self.assertNotIn(transferred_location.name, specs["location"])
1172+
self.assertIn(location.name, specs["location"])
1173+
1174+
with self.subTest("the filters do not wait for the commands to be created"):
1175+
fresh = self._create_batch_command(organization=org)
1176+
specs = _filter_specs(
1177+
reverse(f"admin:{self.app_label}_batchcommand_change", args=[fresh.pk])
1178+
)
1179+
self.assertIn(group.name, specs["device group"])
11441180
self.assertIn(location.name, specs["location"])
1145-
self._login()
1146-
specs = {
1147-
str(spec.title): [str(choice["display"]) for choice in spec.choices]
1148-
for spec in self.client.get(url).context["filter_specs"]
1149-
}
1181+
self.assertNotIn("organization", specs)
1182+
1183+
with self.subTest("the target of the batch is not offered as a filter"):
1184+
targeted = self._create_batch_command(organization=org, group=group)
1185+
specs = _filter_specs(
1186+
reverse(
1187+
f"admin:{self.app_label}_batchcommand_change", args=[targeted.pk]
1188+
)
1189+
)
1190+
self.assertNotIn("device group", specs)
1191+
self.assertIn("location", specs)
1192+
targeted = self._create_batch_command(organization=org, location=location)
1193+
specs = _filter_specs(
1194+
reverse(
1195+
f"admin:{self.app_label}_batchcommand_change", args=[targeted.pk]
1196+
)
1197+
)
1198+
self.assertNotIn("location", specs)
1199+
self.assertIn("device group", specs)
1200+
1201+
with self.subTest("a system wide batch offers every organization"):
1202+
system_wide = self._create_batch_command(organization=None)
1203+
specs = _filter_specs(
1204+
reverse(
1205+
f"admin:{self.app_label}_batchcommand_change",
1206+
args=[system_wide.pk],
1207+
)
1208+
)
1209+
self.assertIn(org.name, specs["organization"])
1210+
self.assertIn(org2.name, specs["organization"])
11501211
self.assertIn(transferred_group.name, specs["device group"])
11511212
self.assertIn(transferred_location.name, specs["location"])
11521213

openwisp_controller/connection/tests/test_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,7 @@ def test_batch_command_skipped_devices(self):
10781078
"status_display": "skipped",
10791079
"output": "error 0",
10801080
"modified": None,
1081+
"modified_display": "",
10811082
"is_skipped": True,
10821083
},
10831084
)

0 commit comments

Comments
 (0)