diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py
index f15c98d58..970eae743 100644
--- a/openwisp_controller/connection/admin.py
+++ b/openwisp_controller/connection/admin.py
@@ -4,23 +4,27 @@
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
+from .filters import GroupFilter, LocationFilter, TypeFilter
from .schema import schema
from .widgets import CommandSchemaWidget, CredentialsSchemaWidget
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,386 @@ def schema_view(self, request):
CommandInline,
]
DeviceAdmin.add_reversion_following(follow=["deviceconnection_set"])
+
+
+class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin):
+ list_display = [
+ "label",
+ "organization_display",
+ "colored_status",
+ "type",
+ "affected_devices",
+ "created",
+ ]
+ ordering = ("-created",)
+ list_filter = [
+ MultitenantOrgFilter,
+ "status",
+ TypeFilter,
+ GroupFilter,
+ LocationFilter,
+ ]
+ list_select_related = ("organization",)
+ search_fields = [
+ "label",
+ "notes",
+ "organization__name",
+ "devices__name",
+ "location__name",
+ "group__name",
+ ]
+ change_form_template = (
+ "admin/connection/batch_command/batch_command_change_form.html"
+ )
+ device_commands_per_page = 20
+ exclude = ("devices",)
+ fields = [
+ "organization_display",
+ "label",
+ "notes",
+ "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 = {
+ "all": [
+ "admin/css/changelists.css",
+ "admin/css/ow-filters.css",
+ "connection/css/batch-command.css",
+ ]
+ }
+
+ def get_readonly_fields(self, request, obj=None):
+ 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:
+ 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 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 "-"
+ Device = swapper.load_model("config", "Device")
+ 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 = devices.get(pk_str)
+ 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,
+ obj,
+ current_status,
+ current_location=None,
+ current_group=None,
+ current_org=None,
+ ):
+ 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())
+
+ # 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):
+ 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 = self._get_commands(request, obj)
+ 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", "")
+ 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(
+ {
+ "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"):
+ pks = list(obj.skipped_devices.keys())
+ 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)
+ 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(
+ {
+ "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,
+ 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)
+ )
+ 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..1037f9ece 100644
--- a/openwisp_controller/connection/base/models.py
+++ b/openwisp_controller/connection/base/models.py
@@ -809,6 +809,10 @@ def __str__(self):
@cached_property
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
diff --git a/openwisp_controller/connection/filters.py b/openwisp_controller/connection/filters.py
new file mode 100644
index 000000000..7d03b9be0
--- /dev/null
+++ b/openwisp_controller/connection/filters.py
@@ -0,0 +1,36 @@
+from django.contrib import admin
+from django.utils.translation import gettext_lazy as _
+from swapper import load_model
+
+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")
+
+
+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
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/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..43ae2a83c
--- /dev/null
+++ b/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html
@@ -0,0 +1,173 @@
+{% 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 %}
+
+
+
+
+
+
+
+
+
+
+ {% for spec in filter_specs %}
+
+ {% for choice in spec.choices %}
+ {% if choice.selected %}
+
+
+ {% blocktrans with filter_title=spec.title %} By {{ filter_title }} {% endblocktrans %}
+
+
+ {{ choice.display }}
+
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+
+
{% trans 'Filter' %}
+
+ {% if has_active_filters %}
+
+ {% endif %}
+ {% if filter_specs|length > 4 %}
+
+ {% endif %}
+
+
+
+{% endif %}
+
+
+
+
+
+
+
+
+
+ | {% trans "Device" %} |
+ {% trans "Status" %} |
+ {% trans "Output" %} |
+ {% trans "Timestamp" %} |
+
+
+
+ {% for command in commands %}
+
+ |
+ {% if command.is_skipped %}
+ {{ command.device_name }}
+ {% else %}
+
+ {{ command.device_name }}
+
+ {% endif %}
+ |
+
+ {{ command.status_display }}
+ |
+
+ {{ command.output|default:"-" }}
+ |
+ {{ command.created|date|default:"-" }} |
+
+ {% empty %}
+
+ | {% trans "No commands found." %} |
+
+ {% endfor %}
+
+
+
+
+ {% 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 %}