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 %} +
+
+ + {% 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 %}