From 72a7b9a9dc241d33538063948c1f86987aa7f8b7 Mon Sep 17 00:00:00 2001 From: dee077 Date: Wed, 1 Jul 2026 03:47:25 +0530 Subject: [PATCH 1/4] [feature] Add Django admin workflow for mass command execution and real-time monitoring #1345 - Custom admin change form with filtered/paginated commands table - Merged skipped device rows into main commands table - Colored status using CSS variables - Real-time polling for in-progress batches - Custom CSS and JS for batch command admin Fixes #1345 --- openwisp_controller/connection/admin.py | 210 +++++++++++++++++- openwisp_controller/connection/base/models.py | 2 +- .../static/connection/css/batch-command.css | 144 ++++++++++++ .../static/connection/js/batch-command.js | 43 ++++ .../batch_command_change_form.html | 174 +++++++++++++++ 5 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 openwisp_controller/connection/static/connection/css/batch-command.css create mode 100644 openwisp_controller/connection/static/connection/js/batch-command.js create mode 100644 openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py index f15c98d58..60d853b7c 100644 --- a/openwisp_controller/connection/admin.py +++ b/openwisp_controller/connection/admin.py @@ -1,17 +1,20 @@ +import json from datetime import timedelta import reversion import swapper from django import forms from django.contrib import admin +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.http import HttpResponseForbidden, JsonResponse from django.urls import path, resolve -from django.utils.html import format_html +from django.utils.html import format_html, format_html_join +from django.utils.safestring import mark_safe from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ from openwisp_users.multitenancy import MultitenantOrgFilter -from openwisp_utils.admin import TimeReadonlyAdminMixin +from openwisp_utils.admin import ReadOnlyAdmin, TimeReadonlyAdminMixin from ..admin import MultitenantAdminMixin from ..config.admin import DeactivatedDeviceReadOnlyMixin, DeviceAdmin @@ -21,6 +24,7 @@ Credentials = swapper.load_model("connection", "Credentials") DeviceConnection = swapper.load_model("connection", "DeviceConnection") Command = swapper.load_model("connection", "Command") +BatchCommand = swapper.load_model("connection", "BatchCommand") class CredentialsForm(forms.ModelForm): @@ -215,3 +219,205 @@ def schema_view(self, request): CommandInline, ] DeviceAdmin.add_reversion_following(follow=["deviceconnection_set"]) + + +class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): + ordering = ("-created",) + list_display = [ + "id", + "organization_display", + "status", + "type", + "created", + "total_devices", + ] + list_filter = [MultitenantOrgFilter, "status", "type"] + list_select_related = ("organization",) + search_fields = ["id"] + change_form_template = ( + "admin/connection/batch_command/batch_command_change_form.html" + ) + device_commands_per_page = 20 + exclude = ("devices",) + fields = [ + "organization_display", + "total_devices", + "colored_status", + "type", + "formatted_input", + "group", + "location", + "display_skipped_devices", + "created", + "modified", + ] + + class Media: + css = { + "all": [ + "admin/css/changelists.css", + "admin/css/ow-filters.css", + "connection/css/batch-command.css", + ] + } + js = [ + "admin/js/ow-filter.js", + "connection/js/batch-command.js", + ] + + def get_readonly_fields(self, request, obj=None): + return self.fields or [] + + def organization_display(self, obj): + if obj.organization: + return obj.organization.name + return _("All") + + organization_display.short_description = _("organization") + organization_display.admin_order_field = "organization" + + def colored_status(self, obj): + css_class = f"command-status {obj.status}" + return format_html( + '{1}', + css_class, + obj.get_status_display(), + ) + + colored_status.short_description = _("status") + + def formatted_input(self, obj): + if not obj.input: + return "-" + return obj.input.get("command", obj.input) + + formatted_input.short_description = _("input") + + def display_skipped_devices(self, obj): + if not obj.skipped_devices: + return "-" + Device = swapper.load_model("config", "Device") + count = len(obj.skipped_devices) + lines = [str(count)] + for pk_str, errors in obj.skipped_devices.items(): + device = Device.objects.filter(pk=pk_str).first() + name = device.name if device else _("Deleted ({})").format(pk_str) + lines.append(format_html("{}: {}", name, ", ".join(errors))) + return format_html( + '
{}
', + format_html_join(mark_safe("
"), "{}", ((line,) for line in lines)), + ) + + display_skipped_devices.short_description = _("Skipped devices") + + def _build_filter_specs(self, request, current_status): + filter_specs = [] + params = request.GET.copy() + + def _make_choice(current_value, display, param_name, value): + q = params.copy() + q.pop(param_name, None) + if value: + q[param_name] = value + qs = q.urlencode() + query_string = f"?{qs}" if qs else "" + return { + "display": display, + "selected": current_value == value, + "query_string": query_string, + } + + status_choices = [] + for status_value, display_name in ( + (("", _("All")),) + Command.STATUS_CHOICES + (("skipped", _("Skipped")),) + ): + status_choices.append( + _make_choice(current_status, display_name, "status", status_value) + ) + + class StatusFilter: + title = _("status") + choices = status_choices + + filter_specs.append(StatusFilter()) + return filter_specs + + def _paginate_commands(self, items, page_param, per_page=None): + per_page = per_page or self.device_commands_per_page + paginator = Paginator(list(items), per_page) + page_number = page_param or 1 + try: + page_obj = paginator.page(page_number) + except (PageNotAnInteger, EmptyPage): + page_obj = paginator.page(1) + return page_obj, paginator, page_obj.object_list + + def change_view(self, request, object_id, form_url="", extra_context=None): + extra_context = extra_context or {} + obj = self.get_object(request, object_id) + if obj: + Device = swapper.load_model("config", "Device") + commands_qs = Command.objects.filter(batch_command=obj).select_related( + "device" + ) + search_query = request.GET.get("q", "") + if search_query: + commands_qs = commands_qs.filter(device__name__icontains=search_query) + current_status = request.GET.get("status", "") + if current_status and current_status != "skipped": + commands_qs = commands_qs.filter(status=current_status) + rows = [] + for cmd in commands_qs: + rows.append( + { + "device_name": cmd.device.name, + "device_pk": cmd.device.pk, + "status": cmd.status, + "status_display": cmd.get_status_display(), + "output": (cmd.output or "").lstrip(), + "created": cmd.created, + "is_skipped": False, + } + ) + if obj.skipped_devices and current_status in ("", "skipped"): + for pk_str, errors in obj.skipped_devices.items(): + device = Device.objects.filter(pk=pk_str).first() + name = device.name if device else _("Deleted ({})").format(pk_str) + if search_query and search_query.lower() not in name.lower(): + continue + rows.append( + { + "device_name": name, + "device_pk": pk_str, + "status": "skipped", + "status_display": _("Skipped"), + "output": ", ".join(errors), + "created": None, + "is_skipped": True, + } + ) + + def _sort_key(row): + priority = {"success": 0, "failed": 1, "skipped": 2} + return (priority.get(row["status"], 99), row["device_name"].lower()) + + rows.sort(key=_sort_key) + filter_specs = self._build_filter_specs(request, current_status) + page_obj, paginator, commands = self._paginate_commands( + rows, request.GET.get("page", 1) + ) + extra_context.update( + { + "commands": commands, + "page_obj": page_obj, + "paginator": paginator, + "filter_specs": filter_specs, + "has_active_filters": any( + request.GET.get(param) for param in ["status"] + ), + } + ) + return super().change_view(request, object_id, extra_context=extra_context) + + +admin.site.register(BatchCommand, BatchCommandAdmin) diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index 75290a2d7..edc972df2 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -809,7 +809,7 @@ def __str__(self): @cached_property def total_devices(self): - return self.batch_commands.count() + return self.batch_commands.count() + len(self.skipped_devices or {}) @property def successful(self): diff --git a/openwisp_controller/connection/static/connection/css/batch-command.css b/openwisp_controller/connection/static/connection/css/batch-command.css new file mode 100644 index 000000000..cff9510f1 --- /dev/null +++ b/openwisp_controller/connection/static/connection/css/batch-command.css @@ -0,0 +1,144 @@ +#batchcommand_form .submit-row { + display: none; +} + +.commands-title { + font-size: 22px; + font-weight: 300; + margin: 0; + padding: 0; +} + +.search-section { + padding: 20px; +} + +.search-form { + display: flex; + align-items: center; +} + +#main #content .search-icon { + width: 20px; + height: 20px; + margin-right: 15px; + font-size: 16px; + margin-top: -3px; +} + +#main #content .search-input { + padding: 10px 15px; +} + +#main #content .search-button { + padding: 10px 20px; + margin-left: 15px; +} + +.filter-clear-link:hover { + color: var(--ow-color-fg-darker); +} + +.results-table { + width: 100%; + border-collapse: collapse; +} + +#main #content .device-link { + color: var(--ow-color-primary); + font-weight: bold; +} + +.device-name-disabled { + color: var(--body-quiet-color); + font-style: italic; +} + +.empty-results { + padding: 40px; + text-align: center; + color: var(--body-quiet-color); + font-style: italic; +} + +.pagination { + padding: 15px 20px; + text-align: right; + border-top: 2px solid var(--hairline-color); + background: var(--darkened-bg); +} + +.pagination a { + color: var(--body-fg); + text-decoration: none; + margin: 0 5px; +} + +.pagination .current-page { + margin: 0 10px; + color: var(--body-quiet-color); +} + +.paginator { + color: var(--body-quiet-color); + padding: 10px 20px; + border-bottom: 1px solid var(--hairline-color); + margin: 0; +} + +.command-status { + font-weight: bold; +} + +.command-status.success { + color: var(--ow-color-success); +} + +.command-status.failed { + color: var(--error-fg); +} + +.command-status.in-progress { + color: var(--body-quiet-color); +} + +.command-status.skipped { + color: var(--body-quiet-color); + opacity: 0.7; +} + +.command-output { + padding: 0; +} + +.command-output pre { + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + padding: 0; + font: inherit; + color: inherit; + background: transparent; +} + +.skipped-devices-list { + line-height: 1.7; +} + +.field-display_skipped_devices .readonly.readonly { + padding: 0; +} + +/* Adjustments for list filters */ +#main #content .left-arrow { + left: -1.125rem; +} +#main #content .right-arrow { + right: -1.125rem; +} +#main #content #ow-changelist-filter { + padding: 1.25rem 0rem; +} +#main #content .filters-top { + margin-bottom: 0.5rem; +} diff --git a/openwisp_controller/connection/static/connection/js/batch-command.js b/openwisp_controller/connection/static/connection/js/batch-command.js new file mode 100644 index 000000000..b066770aa --- /dev/null +++ b/openwisp_controller/connection/static/connection/js/batch-command.js @@ -0,0 +1,43 @@ +(function () { + "use strict"; + + var pollInterval = 3000; + var pollTimer = null; + + function getBatchStatus() { + var statusEl = document.querySelector(".field-status .readonly"); + if (!statusEl) return null; + var text = statusEl.textContent.trim().toLowerCase(); + if (text.indexOf("in progress") !== -1) return "in-progress"; + if (text.indexOf("success") !== -1) return "success"; + if (text.indexOf("failed") !== -1) return "failed"; + if (text.indexOf("idle") !== -1) return "idle"; + return null; + } + + function shouldPoll() { + var status = getBatchStatus(); + return status === "in-progress" || status === "idle"; + } + + function reloadPage() { + window.location.reload(); + } + + function startPolling() { + stopPolling(); + if (!shouldPoll()) return; + pollTimer = setInterval(reloadPage, pollInterval); + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + document.addEventListener("DOMContentLoaded", function () { + startPolling(); + }); +})(); diff --git a/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html b/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html new file mode 100644 index 000000000..81d0e812d --- /dev/null +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html @@ -0,0 +1,174 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static admin_list ow_tags %} + +{% block extrahead %} +{{ block.super }} + + + +{% endblock %} + +{% block content %} +{{ block.super }} + + +

{% trans "Commands" %}

+ + +{% if filter_specs %} +
+
+ + {% trans 'left' %} + + + {% trans 'right' %} + +
+
+ {% for spec in filter_specs %} +
+ {% for choice in spec.choices %} + {% if choice.selected %} + + {% endif %} + {% endfor %} +
+ +
+
+ {% endfor %} +
+
+
+
+

{% trans 'Filter' %}

+
+ {% if has_active_filters %} +

+ ✖ {% trans "Clear all filters" %} +

+ {% endif %} + {% if filter_specs|length > 4 %} + + {% endif %} +
+
+
+{% endif %} + + +
+
+ + + + + + + {% for param, value in request.GET.items %} + {% if param != 'q' and param != 'page' %} + + {% endif %} + {% endfor %} +
+
+ + +
+ + + + + + + + + + + {% for command in commands %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Device" %}{% trans "Status" %}{% trans "Output" %}{% trans "Timestamp" %}
+ {% if command.is_skipped %} + {{ command.device_name }} + {% else %} + + {{ command.device_name }} + + {% endif %} + + {{ command.status_display }} + +
{{ command.output|default:"-" }}
+
{{ command.created|date|default:"-" }}
{% trans "No commands found." %}
+ + + {% if paginator %} +

+ {% blocktrans count counter=paginator.count %} + {{ counter }} command + {% plural %}{{ counter }} commands + {% endblocktrans %} +

+ {% endif %} + + + {% if page_obj.has_other_pages %} + + {% endif %} +
+{% endblock %} + +{% block footer %} +{{ block.super }} + + +{% endblock %} From 9ea3ae2807c9b0e19cc21f36112bb9c3126514b7 Mon Sep 17 00:00:00 2001 From: dee077 Date: Thu, 2 Jul 2026 23:49:48 +0530 Subject: [PATCH 2/4] [feature] Add affected_devices, colored changelist status, and label admin link - Add cached_property on AbstractBatchCommand (excludes skipped) - Use in changelist list_display for consistent status colors - Replace ID with label as the clickable link in admin changelist - Add CSS to command-inline.css for consistency - Add label, notes to change form fields; reorder columns (created last, affected_devices before created) --- openwisp_controller/connection/admin.py | 18 +++++++++++++----- openwisp_controller/connection/base/models.py | 4 ++++ .../static/connection/css/command-inline.css | 4 ++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py index 60d853b7c..e999dc073 100644 --- a/openwisp_controller/connection/admin.py +++ b/openwisp_controller/connection/admin.py @@ -224,16 +224,17 @@ def schema_view(self, request): class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): ordering = ("-created",) list_display = [ - "id", + "label", "organization_display", - "status", + "colored_status", "type", + "affected_devices", "created", - "total_devices", ] + list_display_links = ["label"] list_filter = [MultitenantOrgFilter, "status", "type"] list_select_related = ("organization",) - search_fields = ["id"] + search_fields = ["label"] change_form_template = ( "admin/connection/batch_command/batch_command_change_form.html" ) @@ -241,7 +242,9 @@ class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): exclude = ("devices",) fields = [ "organization_display", - "total_devices", + "label", + "notes", + "affected_devices", "colored_status", "type", "formatted_input", @@ -293,6 +296,11 @@ def formatted_input(self, obj): formatted_input.short_description = _("input") + def affected_devices(self, obj): + return obj.affected_devices + + affected_devices.short_description = _("affected devices") + def display_skipped_devices(self, obj): if not obj.skipped_devices: return "-" diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index edc972df2..1037f9ece 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -811,6 +811,10 @@ def __str__(self): def total_devices(self): return self.batch_commands.count() + len(self.skipped_devices or {}) + @cached_property + def affected_devices(self): + return self.batch_commands.count() + @property def successful(self): return self.batch_commands.filter(status="success").count() diff --git a/openwisp_controller/connection/static/connection/css/command-inline.css b/openwisp_controller/connection/static/connection/css/command-inline.css index 0da80c341..8deda6c5e 100644 --- a/openwisp_controller/connection/static/connection/css/command-inline.css +++ b/openwisp_controller/connection/static/connection/css/command-inline.css @@ -242,6 +242,10 @@ li.commands:not(.recent) { .command-status.in-progress { color: var(--body-quiet-color); } +.command-status.skipped { + color: var(--body-quiet-color); + opacity: 0.7; +} .command-status { font-weight: bold; } From 4c821c27723e155ae4d01f62ce782df0426cb3ac Mon Sep 17 00:00:00 2001 From: dee077 Date: Tue, 7 Jul 2026 02:50:32 +0530 Subject: [PATCH 3/4] [fix] Restructure --- openwisp_controller/connection/admin.py | 68 ++++++++++++++----- openwisp_controller/connection/filters.py | 15 ++++ .../static/connection/css/command-inline.css | 4 -- .../static/connection/js/batch-command.js | 43 ------------ .../batch_command_change_form.html | 1 - 5 files changed, 65 insertions(+), 66 deletions(-) create mode 100644 openwisp_controller/connection/filters.py delete mode 100644 openwisp_controller/connection/static/connection/js/batch-command.js diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py index e999dc073..2409c6595 100644 --- a/openwisp_controller/connection/admin.py +++ b/openwisp_controller/connection/admin.py @@ -18,6 +18,7 @@ from ..admin import MultitenantAdminMixin from ..config.admin import DeactivatedDeviceReadOnlyMixin, DeviceAdmin +from .filters import GroupFilter, LocationFilter from .schema import schema from .widgets import CommandSchemaWidget, CredentialsSchemaWidget @@ -222,7 +223,6 @@ def schema_view(self, request): class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): - ordering = ("-created",) list_display = [ "label", "organization_display", @@ -231,10 +231,23 @@ class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): "affected_devices", "created", ] - list_display_links = ["label"] - list_filter = [MultitenantOrgFilter, "status", "type"] + ordering = ("-created",) + list_filter = [ + MultitenantOrgFilter, + "status", + "type", + GroupFilter, + LocationFilter, + ] list_select_related = ("organization",) - search_fields = ["label"] + search_fields = [ + "label", + "notes", + "organization__name", + "devices__name", + "location__name", + "group__name", + ] change_form_template = ( "admin/connection/batch_command/batch_command_change_form.html" ) @@ -244,16 +257,28 @@ class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): "organization_display", "label", "notes", - "affected_devices", "colored_status", "type", "formatted_input", + "affected_devices", "group", "location", "display_skipped_devices", "created", "modified", ] + readonly_fields = [ + "organization_display", + "colored_status", + "type", + "formatted_input", + "affected_devices", + "display_skipped_devices", + "group", + "location", + "created", + "modified", + ] class Media: css = { @@ -263,13 +288,18 @@ class Media: "connection/css/batch-command.css", ] } - js = [ - "admin/js/ow-filter.js", - "connection/js/batch-command.js", - ] def get_readonly_fields(self, request, obj=None): - return self.fields or [] + fields = super().get_readonly_fields(request, obj) + return fields + list(self.__class__.readonly_fields) + + def _get_commands(self, request, obj): + qs = Command.objects.filter(batch_command=obj).select_related("device") + if not request.user.is_superuser: + qs = qs.filter( + device__organization_id__in=request.user.organizations_managed + ) + return qs def organization_display(self, obj): if obj.organization: @@ -305,10 +335,12 @@ def display_skipped_devices(self, obj): if not obj.skipped_devices: return "-" Device = swapper.load_model("config", "Device") - count = len(obj.skipped_devices) + pks = list(obj.skipped_devices.keys()) + devices = {str(d.pk): d for d in Device.objects.filter(pk__in=pks)} + count = len(pks) lines = [str(count)] for pk_str, errors in obj.skipped_devices.items(): - device = Device.objects.filter(pk=pk_str).first() + device = devices.get(pk_str) name = device.name if device else _("Deleted ({})").format(pk_str) lines.append(format_html("{}: {}", name, ", ".join(errors))) return format_html( @@ -318,7 +350,7 @@ def display_skipped_devices(self, obj): display_skipped_devices.short_description = _("Skipped devices") - def _build_filter_specs(self, request, current_status): + def _build_filter_specs(self, request, obj, current_status): filter_specs = [] params = request.GET.copy() @@ -365,9 +397,7 @@ def change_view(self, request, object_id, form_url="", extra_context=None): obj = self.get_object(request, object_id) if obj: Device = swapper.load_model("config", "Device") - commands_qs = Command.objects.filter(batch_command=obj).select_related( - "device" - ) + commands_qs = self._get_commands(request, obj) search_query = request.GET.get("q", "") if search_query: commands_qs = commands_qs.filter(device__name__icontains=search_query) @@ -388,8 +418,10 @@ def change_view(self, request, object_id, form_url="", extra_context=None): } ) if obj.skipped_devices and current_status in ("", "skipped"): + pks = list(obj.skipped_devices.keys()) + devices = {str(d.pk): d for d in Device.objects.filter(pk__in=pks)} for pk_str, errors in obj.skipped_devices.items(): - device = Device.objects.filter(pk=pk_str).first() + device = devices.get(pk_str) name = device.name if device else _("Deleted ({})").format(pk_str) if search_query and search_query.lower() not in name.lower(): continue @@ -410,7 +442,7 @@ def _sort_key(row): return (priority.get(row["status"], 99), row["device_name"].lower()) rows.sort(key=_sort_key) - filter_specs = self._build_filter_specs(request, current_status) + filter_specs = self._build_filter_specs(request, obj, current_status) page_obj, paginator, commands = self._paginate_commands( rows, request.GET.get("page", 1) ) diff --git a/openwisp_controller/connection/filters.py b/openwisp_controller/connection/filters.py new file mode 100644 index 000000000..b0623c387 --- /dev/null +++ b/openwisp_controller/connection/filters.py @@ -0,0 +1,15 @@ +from django.utils.translation import gettext_lazy as _ + +from openwisp_users.multitenancy import MultitenantRelatedOrgFilter + + +class GroupFilter(MultitenantRelatedOrgFilter): + field_name = "group" + parameter_name = "group_id" + title = _("group") + + +class LocationFilter(MultitenantRelatedOrgFilter): + field_name = "location" + parameter_name = "location_id" + title = _("location") diff --git a/openwisp_controller/connection/static/connection/css/command-inline.css b/openwisp_controller/connection/static/connection/css/command-inline.css index 8deda6c5e..0da80c341 100644 --- a/openwisp_controller/connection/static/connection/css/command-inline.css +++ b/openwisp_controller/connection/static/connection/css/command-inline.css @@ -242,10 +242,6 @@ li.commands:not(.recent) { .command-status.in-progress { color: var(--body-quiet-color); } -.command-status.skipped { - color: var(--body-quiet-color); - opacity: 0.7; -} .command-status { font-weight: bold; } diff --git a/openwisp_controller/connection/static/connection/js/batch-command.js b/openwisp_controller/connection/static/connection/js/batch-command.js deleted file mode 100644 index b066770aa..000000000 --- a/openwisp_controller/connection/static/connection/js/batch-command.js +++ /dev/null @@ -1,43 +0,0 @@ -(function () { - "use strict"; - - var pollInterval = 3000; - var pollTimer = null; - - function getBatchStatus() { - var statusEl = document.querySelector(".field-status .readonly"); - if (!statusEl) return null; - var text = statusEl.textContent.trim().toLowerCase(); - if (text.indexOf("in progress") !== -1) return "in-progress"; - if (text.indexOf("success") !== -1) return "success"; - if (text.indexOf("failed") !== -1) return "failed"; - if (text.indexOf("idle") !== -1) return "idle"; - return null; - } - - function shouldPoll() { - var status = getBatchStatus(); - return status === "in-progress" || status === "idle"; - } - - function reloadPage() { - window.location.reload(); - } - - function startPolling() { - stopPolling(); - if (!shouldPoll()) return; - pollTimer = setInterval(reloadPage, pollInterval); - } - - function stopPolling() { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - } - - document.addEventListener("DOMContentLoaded", function () { - startPolling(); - }); -})(); diff --git a/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html b/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html index 81d0e812d..43ae2a83c 100644 --- a/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html @@ -170,5 +170,4 @@

{% block footer %} {{ block.super }} - {% endblock %} From f3f90e44f2c03ca032c56ff5a50e243f32670950 Mon Sep 17 00:00:00 2001 From: dee077 Date: Fri, 24 Jul 2026 19:15:08 +0530 Subject: [PATCH 4/4] [fix] Add filters --- openwisp_controller/connection/admin.py | 155 +++++++++++++++++++++- openwisp_controller/connection/filters.py | 21 +++ 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py index 2409c6595..970eae743 100644 --- a/openwisp_controller/connection/admin.py +++ b/openwisp_controller/connection/admin.py @@ -1,4 +1,3 @@ -import json from datetime import timedelta import reversion @@ -18,7 +17,7 @@ from ..admin import MultitenantAdminMixin from ..config.admin import DeactivatedDeviceReadOnlyMixin, DeviceAdmin -from .filters import GroupFilter, LocationFilter +from .filters import GroupFilter, LocationFilter, TypeFilter from .schema import schema from .widgets import CommandSchemaWidget, CredentialsSchemaWidget @@ -235,7 +234,7 @@ class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): list_filter = [ MultitenantOrgFilter, "status", - "type", + TypeFilter, GroupFilter, LocationFilter, ] @@ -350,7 +349,15 @@ def display_skipped_devices(self, obj): display_skipped_devices.short_description = _("Skipped devices") - def _build_filter_specs(self, request, obj, current_status): + def _build_filter_specs( + self, + request, + obj, + current_status, + current_location=None, + current_group=None, + current_org=None, + ): filter_specs = [] params = request.GET.copy() @@ -380,6 +387,103 @@ class StatusFilter: choices = status_choices filter_specs.append(StatusFilter()) + + # Location filter + Device = swapper.load_model("config", "Device") + location_qs = ( + Device.objects.filter(command__batch_command=obj) + .exclude(devicelocation__location__isnull=True) + .values_list( + "devicelocation__location__id", + "devicelocation__location__name", + ) + .distinct() + ) + location_choices = [] + location_choices.append( + _make_choice(current_location or "", _("All"), "location_id", "") + ) + for loc_id, loc_name in location_qs: + if loc_id: + location_choices.append( + _make_choice( + current_location or "", + loc_name, + "location_id", + str(loc_id), + ) + ) + + if len(location_choices) > 1: + + class LocationFilterCls: + title = _("location") + choices = location_choices + + filter_specs.append(LocationFilterCls()) + + # Group filter + group_qs = ( + Device.objects.filter( + command__batch_command=obj, + group__isnull=False, + ) + .values_list("group__id", "group__name") + .distinct() + ) + group_choices = [] + group_choices.append( + _make_choice(current_group or "", _("All"), "group_id", "") + ) + for grp_id, grp_name in group_qs: + if grp_id: + group_choices.append( + _make_choice( + current_group or "", + grp_name, + "group_id", + str(grp_id), + ) + ) + + if len(group_choices) > 1: + + class GroupFilterCls: + title = _("device group") + choices = group_choices + + filter_specs.append(GroupFilterCls()) + + # Organization filter (superusers only) + if request.user.is_superuser: + org_qs = ( + Device.objects.filter(command__batch_command=obj) + .values_list("organization__id", "organization__name") + .distinct() + ) + org_choices = [] + org_choices.append( + _make_choice(current_org or "", _("All"), "organization_id", "") + ) + for org_id, org_name in org_qs: + if org_id: + org_choices.append( + _make_choice( + current_org or "", + org_name, + "organization_id", + str(org_id), + ) + ) + + if len(org_choices) > 1: + + class OrganizationFilterCls: + title = _("organization") + choices = org_choices + + filter_specs.append(OrganizationFilterCls()) + return filter_specs def _paginate_commands(self, items, page_param, per_page=None): @@ -402,8 +506,19 @@ def change_view(self, request, object_id, form_url="", extra_context=None): if search_query: commands_qs = commands_qs.filter(device__name__icontains=search_query) current_status = request.GET.get("status", "") + current_location = request.GET.get("location_id", "") + current_group = request.GET.get("group_id", "") + current_org = request.GET.get("organization_id", "") if current_status and current_status != "skipped": commands_qs = commands_qs.filter(status=current_status) + if current_location: + commands_qs = commands_qs.filter( + device__devicelocation__location_id=current_location + ) + if current_group: + commands_qs = commands_qs.filter(device__group_id=current_group) + if current_org: + commands_qs = commands_qs.filter(device__organization_id=current_org) rows = [] for cmd in commands_qs: rows.append( @@ -419,10 +534,29 @@ def change_view(self, request, object_id, form_url="", extra_context=None): ) if obj.skipped_devices and current_status in ("", "skipped"): pks = list(obj.skipped_devices.keys()) - devices = {str(d.pk): d for d in Device.objects.filter(pk__in=pks)} + device_qs = Device.objects.filter(pk__in=pks) + if current_location: + DeviceLocation = swapper.load_model("geo", "DeviceLocation") + device_locations = set( + DeviceLocation.objects.filter( + device_id__in=pks, + location_id=current_location, + ).values_list("device_id", flat=True) + ) + else: + device_locations = None + devices = {str(d.pk): d for d in device_qs} for pk_str, errors in obj.skipped_devices.items(): device = devices.get(pk_str) - name = device.name if device else _("Deleted ({})").format(pk_str) + if not device: + continue + if current_org and str(device.organization_id) != current_org: + continue + if current_group and str(device.group_id) != current_group: + continue + if current_location and pk_str not in device_locations: + continue + name = device.name if search_query and search_query.lower() not in name.lower(): continue rows.append( @@ -442,7 +576,14 @@ def _sort_key(row): return (priority.get(row["status"], 99), row["device_name"].lower()) rows.sort(key=_sort_key) - filter_specs = self._build_filter_specs(request, obj, current_status) + filter_specs = self._build_filter_specs( + request, + obj, + current_status, + current_location=current_location, + current_group=current_group, + current_org=current_org, + ) page_obj, paginator, commands = self._paginate_commands( rows, request.GET.get("page", 1) ) diff --git a/openwisp_controller/connection/filters.py b/openwisp_controller/connection/filters.py index b0623c387..7d03b9be0 100644 --- a/openwisp_controller/connection/filters.py +++ b/openwisp_controller/connection/filters.py @@ -1,4 +1,6 @@ +from django.contrib import admin from django.utils.translation import gettext_lazy as _ +from swapper import load_model from openwisp_users.multitenancy import MultitenantRelatedOrgFilter @@ -13,3 +15,22 @@ class LocationFilter(MultitenantRelatedOrgFilter): field_name = "location" parameter_name = "location_id" title = _("location") + + +class TypeFilter(admin.SimpleListFilter): + title = _("type") + parameter_name = "type" + + def lookups(self, request, model_admin): + BatchCommand = load_model("connection", "BatchCommand") + qs = BatchCommand.objects.all() + if not request.user.is_superuser: + qs = qs.filter(organization_id__in=request.user.organizations_managed) + types = qs.values_list("type", flat=True).distinct() + choices = dict(BatchCommand._meta.get_field("type").choices) + return [(t, choices.get(t, t)) for t in types] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(type=self.value()) + return queryset