From 41257d612034c291c2d33d4eac3962c27a7e2777 Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Thu, 4 Jun 2026 19:52:58 -0300 Subject: [PATCH 1/9] [ci] Fixed ci YAML inconsistencies (for coveralls, etc.) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2824ca0ba..986237cc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,13 @@ on: push: branches: - master + - "gsoc26-*" pull_request: branches: - master - "1.1" - "1.2" - - "gsoc26-x509-certificate-generator-templates" - - "gsoc26-mass-commands" + - "gsoc26-*" jobs: build: @@ -95,7 +95,7 @@ jobs: with: parallel: true format: cobertura - flag-name: python-${{ matrix.env.env }} + flag-name: python-${{ matrix.python-version }}-${{ matrix.django-version }} github-token: ${{ secrets.GITHUB_TOKEN }} fail-on-error: false From d13c4f017512ed0acde11c21147d33dd694f607f Mon Sep 17 00:00:00 2001 From: Deepanshu Sahu Date: Wed, 22 Jul 2026 06:24:46 +0530 Subject: [PATCH 2/9] [feature] Added Mass Command model and REST API #1344 Closes #1344 --------- Co-authored-by: Federico Capoano --- docs/developer/extending.rst | 1 + docs/user/intro.rst | 3 + docs/user/rest-api.rst | 129 +- docs/user/shell-commands.rst | 33 + .../connection/api/serializers.py | 120 ++ openwisp_controller/connection/api/urls.py | 15 + openwisp_controller/connection/api/views.py | 78 + openwisp_controller/connection/base/models.py | 344 +++- ...0011_batchcommand_command_batch_command.py | 181 ++ .../connection/migrations/__init__.py | 27 + openwisp_controller/connection/models.py | 13 +- openwisp_controller/connection/tasks.py | 22 + .../connection/tests/pytest.py | 1 + .../connection/tests/test_api.py | 1513 ++++++++++++++++- .../connection/tests/test_models.py | 797 ++++++++- .../connection/tests/test_selenium.py | 11 +- .../connection/tests/test_tasks.py | 181 ++ openwisp_controller/connection/tests/utils.py | 19 + openwisp_controller/geo/tests/test_api.py | 2 +- .../openwisp2/sample_connection/api/views.py | 24 + ...0005_batchcommand_command_batch_command.py | 176 ++ tests/openwisp2/sample_connection/models.py | 6 + tests/openwisp2/settings.py | 1 + 23 files changed, 3676 insertions(+), 21 deletions(-) create mode 100644 openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py create mode 100644 tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py diff --git a/docs/developer/extending.rst b/docs/developer/extending.rst index 97da047d7..7dc06d4a5 100644 --- a/docs/developer/extending.rst +++ b/docs/developer/extending.rst @@ -354,6 +354,7 @@ Once you have created the models, add the following to your CONNECTION_CREDENTIALS_MODEL = "sample_connection.Credentials" CONNECTION_DEVICECONNECTION_MODEL = "sample_connection.DeviceConnection" CONNECTION_COMMAND_MODEL = "sample_connection.Command" + CONNECTION_BATCHCOMMAND_MODEL = "sample_connection.BatchCommand" SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL = "sample_subnet_division.SubnetDivisionRule" SUBNET_DIVISION_SUBNETDIVISIONINDEX_MODEL = "sample_subnet_division.SubnetDivisionIndex" diff --git a/docs/user/intro.rst b/docs/user/intro.rst index 1c4b123ae..a9c9092d4 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -70,6 +70,9 @@ e.g.: - Sending configuration updates. - :doc:`Executing shell commands `. +- :doc:`Executing mass commands `: Run a command on + multiple devices at once, see the :ref:`batch command API + ` for details. - Perform firmware upgrades via the additional :doc:`firmware upgrade module `. diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index c9702920d..f007e3ba3 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -210,7 +210,7 @@ config of a device, curl -X PATCH \ http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ - -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "config": { @@ -225,7 +225,7 @@ the/their {id} from the config of a device, curl -X PATCH \ http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ - -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "config": { @@ -240,7 +240,7 @@ from the config of a device, curl -X PATCH \ http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \ - -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'authorization: Bearer ' \ -H 'cache-control: no-cache' \ -H 'content-type: application/json' \ -H 'postman-token: b3f6a1cc-ff13-5eba-e460-8f394e485801' \ @@ -465,7 +465,7 @@ command type being executed. curl -X POST \ http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/command/ \ - -H 'authorization: Bearer yoursecretauthtoken' \ + -H 'authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "type": "custom", @@ -481,6 +481,117 @@ Get Command Details GET /api/v1/controller/device/{device_id}/command/{command_id}/ +.. _controller_batch_command_api: + +Dry-Run Mass Command +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + GET /api/v1/controller/batch-command/execute/ + +Returns the list of devices that would be targeted without executing +anything. Useful for previewing which devices are affected. + +**Query Parameters:** + +================ ========================================================= +Parameter Description +================ ========================================================= +``organization`` Organization UUID (optional for superusers; set + automatically when ``group`` or ``location`` is provided) +``type`` Command type (optional for dry-run) +``input`` JSON input data for the command (optional for dry-run). + Encode as a URL-encoded JSON object, e.g. + ``?type=custom&input=%7B%22command%22%3A%22uptime%22%7D`` +``devices`` Repeated ``devices`` query parameter, each a device UUID + (optional; when provided, ``group`` and ``location`` are + ignored) +``group`` Device group UUID (optional) +``location`` Location UUID (optional) +================ ========================================================= + +Execute a Mass Command +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + POST /api/v1/controller/batch-command/execute/ + +Creates and executes a batch command on the targeted devices. + +**Request Parameters:** + +================ ========================================================= +Parameter Description +================ ========================================================= +``organization`` Organization UUID (optional for superusers; set + automatically when ``group`` or ``location`` is provided) +``type`` Type of command to execute (**required**) +``input`` Input data for the command (**conditionally required** — + depends on command type) +``label`` A short label to identify this batch command + (**required**) +``notes`` Optional notes (optional) +``devices`` List of device UUIDs (optional; when provided, ``group`` + and ``location`` are ignored) +``group`` Device group UUID (optional) +``location`` Location UUID (optional) +================ ========================================================= + +**Available Command Types:** + +See :ref:`controller_execute_command_api` for available command types and +input formats. + +**Example payload:** + +.. code-block:: json + + { + "organization": "org-uuid", + "type": "custom", + "input": {"command": "uptime"}, + "label": "Check uptime" + } + +**Example request:** + +.. code-block:: shell + + curl -X POST \ + http://127.0.0.1:8000/api/v1/controller/batch-command/execute/ \ + -H 'authorization: Bearer ' \ + -H 'content-type: application/json' \ + -d '{ + "organization": "org-uuid", + "type": "custom", + "input": {"command": "uptime"}, + "label": "Check uptime" + }' + +**Response:** ``201 Created`` with the batch command UUID. + +List Mass Commands +~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + GET /api/v1/controller/batch-command/ + +Returns a paginated list of batch commands with device count and skipped +device information. + +Get Mass Command Detail +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + GET /api/v1/controller/batch-command/{id}/ + +Returns detailed information about a batch command, including the list of +targeted devices. + List Device Groups ~~~~~~~~~~~~~~~~~~ @@ -603,7 +714,7 @@ You can create ``DeviceLocation`` object by using primary keys of existing curl -X PUT \ http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \ - -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "location": "f0cb5762-3711-4791-95b6-c2f6656249fa", @@ -635,7 +746,7 @@ single request. curl -X PUT \ http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \ - -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "location": { @@ -686,7 +797,7 @@ demonstrates creating both ``Location`` and ``FloorPlan`` objects. curl -X PUT \ http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \ - -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'authorization: Bearer ' \ -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \ -F 'location.name=Via del Corso' \ -F 'location.address=Via del Corso, Roma, Italia' \ @@ -716,7 +827,7 @@ plan for that location using this endpoint. curl -X PUT \ http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \ - -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'authorization: Bearer ' \ -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \ -F location=f0cb5762-3711-4791-95b6-c2f6656249fa \ -F floorplan.floor=1 \ @@ -907,7 +1018,7 @@ in a single request. curl -X POST \ http://127.0.0.1:8000/api/v1/controller/location/ \ - -H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \ + -H 'authorization: Bearer ' \ -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \ -F 'name=Via del Corso' \ -F 'address=Via del Corso, Roma, Italia' \ diff --git a/docs/user/shell-commands.rst b/docs/user/shell-commands.rst index a21dc6eb4..d80c59806 100644 --- a/docs/user/shell-commands.rst +++ b/docs/user/shell-commands.rst @@ -183,3 +183,36 @@ How to register or unregister commands Refer to :ref:`registering_unregistering_commands` in the developer documentation. + +.. _mass_commands: + +Mass Commands +------------- + +Mass commands allow you to execute a command on multiple devices +simultaneously, rather than issuing commands one device at a time. This is +useful for rebooting all devices in a group, changing passwords across +multiple devices, or running diagnostics on all devices in an +organization. + +**Targeting options:** + +- ``organization``: All devices in an organization. +- ``devices``: Explicit list of device UUIDs. +- ``group``: Device group UUID. +- ``location``: Location UUID. + +If ``devices`` is provided, ``group`` and ``location`` are ignored. +Otherwise, ``group`` and ``location`` can be used together to narrow the +target set within the organization. + +If no targeting options are provided, the command targets all devices in +the organization. Superusers can omit ``organization`` to target all +devices across organizations. + +For superusers, ``organization`` is set automatically when ``group`` or +``location`` is provided. + +Refer to the :ref:`Batch Command API ` +documentation for the available endpoints, request parameters, and +examples. diff --git a/openwisp_controller/connection/api/serializers.py b/openwisp_controller/connection/api/serializers.py index 142c1c30a..e776def03 100644 --- a/openwisp_controller/connection/api/serializers.py +++ b/openwisp_controller/connection/api/serializers.py @@ -12,6 +12,7 @@ DeviceConnection = load_model("connection", "DeviceConnection") Credentials = load_model("connection", "Credentials") Device = load_model("config", "Device") +BatchCommand = load_model("connection", "BatchCommand") class ValidatedDeviceFieldSerializer(ValidatedModelSerializer): @@ -43,6 +44,10 @@ class CommandSerializer(ValidatedDeviceFieldSerializer): required=False, pk_field=serializers.UUIDField(format="hex_verbose"), ) + batch_command = serializers.PrimaryKeyRelatedField( + read_only=True, + pk_field=serializers.UUIDField(format="hex_verbose"), + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -115,3 +120,118 @@ class Meta: "is_working": {"read_only": True}, } read_only_fields = ("created", "modified") + + +class BatchCommandExecuteSerializer( + FilterSerializerByOrgManaged, serializers.ModelSerializer +): + """ + Parses batch command input. + + It does not save data, so it does not need ValidatedModelSerializer. + BatchCommand.execute() and dry_run() validate the model. + """ + + input = serializers.JSONField(allow_null=True, required=False) + devices = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Device.objects.all(), + required=False, + allow_empty=True, + pk_field=serializers.UUIDField(format="hex_verbose"), + ) + + def __init__(self, *args, dry_run=False, **kwargs): + super().__init__(*args, **kwargs) + self.dry_run = dry_run + if dry_run: + self.fields["type"].required = False + self.fields["label"].required = False + + class Meta: + model = BatchCommand + fields = ( + "organization", + "type", + "input", + "label", + "notes", + "devices", + "group", + "location", + ) + extra_kwargs = { + "organization": {"required": False, "allow_null": True}, + } + + def validate(self, data): + org = data.get("organization") + if not org and not self.context["request"].user.is_superuser: + raise serializers.ValidationError( + _("Only superusers can execute batch commands without an organization.") + ) + # DRF's many=True injects [] for QueryDict even when key is + # absent; remove it so model can distinguish omitted vs explicit []. + if "devices" not in self.initial_data and "devices" in data: + data.pop("devices") + return data + + +class BatchCommandSerializer(BaseSerializer): + device_count = serializers.IntegerField(read_only=True) + skipped_devices = serializers.JSONField(read_only=True) + organization = serializers.PrimaryKeyRelatedField( + read_only=True, + pk_field=serializers.UUIDField(format="hex_verbose"), + ) + group = serializers.PrimaryKeyRelatedField( + read_only=True, + allow_null=True, + pk_field=serializers.UUIDField(format="hex_verbose"), + ) + location = serializers.PrimaryKeyRelatedField( + read_only=True, + allow_null=True, + pk_field=serializers.UUIDField(format="hex_verbose"), + ) + + def to_representation(self, instance): + data = super().to_representation(instance) + # The raw password is visible in API responses between + # when the batch is saved and when the Celery task runs _clean_sensitive_info(). + if instance.type == "change_password": + data["input"] = {"password": "********"} + return data + + class Meta: + model = BatchCommand + fields = ( + "id", + "organization", + "status", + "type", + "input", + "label", + "notes", + "group", + "location", + "device_count", + "skipped_devices", + "created", + "modified", + ) + read_only_fields = ( + "created", + "modified", + ) + + +class BatchCommandDetailSerializer(BatchCommandSerializer): + devices = serializers.PrimaryKeyRelatedField( + many=True, + read_only=True, + pk_field=serializers.UUIDField(format="hex_verbose"), + ) + + class Meta(BatchCommandSerializer.Meta): + fields = BatchCommandSerializer.Meta.fields + ("devices",) diff --git a/openwisp_controller/connection/api/urls.py b/openwisp_controller/connection/api/urls.py index 0df8afa51..c6a9caffc 100644 --- a/openwisp_controller/connection/api/urls.py +++ b/openwisp_controller/connection/api/urls.py @@ -45,6 +45,21 @@ def get_view(name): get_view("deviceconnection_detail_view"), name="deviceconnection_detail", ), + path( + "api/v1/controller/batch-command/", + api_views.batch_command_list_view, + name="batch_command_list", + ), + path( + "api/v1/controller/batch-command//", + api_views.batch_command_detail_view, + name="batch_command_detail", + ), + path( + "api/v1/controller/batch-command/execute/", + api_views.batch_command_execute_view, + name="batch_command_execute", + ), ] diff --git a/openwisp_controller/connection/api/views.py b/openwisp_controller/connection/api/views.py index 6af1270c7..63df3069e 100644 --- a/openwisp_controller/connection/api/views.py +++ b/openwisp_controller/connection/api/views.py @@ -1,12 +1,18 @@ +from django.core.exceptions import ValidationError +from django.db.models import Count from django.utils.translation import gettext_lazy as _ from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema +from rest_framework import status from rest_framework.generics import ( + GenericAPIView, + ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateDestroyAPIView, get_object_or_404, ) +from rest_framework.response import Response from swapper import load_model from openwisp_utils.api.pagination import OpenWispPagination @@ -17,6 +23,9 @@ RelatedDeviceProtectedAPIMixin, ) from .serializers import ( + BatchCommandDetailSerializer, + BatchCommandExecuteSerializer, + BatchCommandSerializer, CommandSerializer, CredentialSerializer, DeviceConnectionSerializer, @@ -26,6 +35,7 @@ Device = load_model("config", "Device") Credentials = load_model("connection", "Credentials") DeviceConnection = load_model("connection", "DeviceConnection") +BatchCommand = load_model("connection", "BatchCommand") class BaseCommandView(RelatedDeviceProtectedAPIMixin): @@ -138,6 +148,71 @@ class DeviceConnectionListCreateView(BaseDeviceConnection, ListCreateAPIView): DeviceConnenctionListCreateView = DeviceConnectionListCreateView +class BatchCommandExecuteView(ProtectedAPIMixin, GenericAPIView): + model = BatchCommand + queryset = BatchCommand.objects.all() + serializer_class = BatchCommandExecuteSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + batch = BatchCommand.execute(**serializer.validated_data) + except ValidationError as e: + return Response( + getattr(e, "message_dict", e.messages), + status=status.HTTP_400_BAD_REQUEST, + ) + return Response({"batch": str(batch.pk)}, status=status.HTTP_201_CREATED) + + def get(self, request): + serializer = self.get_serializer( + data=request.query_params, + dry_run=True, + ) + serializer.is_valid(raise_exception=True) + try: + data = BatchCommand.dry_run(**serializer.validated_data) + except ValidationError as e: + return Response( + getattr(e, "message_dict", e.messages), + status=status.HTTP_400_BAD_REQUEST, + ) + data["devices"] = [str(d.pk) for d in data["devices"]] + return Response(data) + + +class BatchCommandListView(ProtectedAPIMixin, ListAPIView): + queryset = BatchCommand.objects.annotate(device_count=Count("devices")).order_by( + "-created" + ) + serializer_class = BatchCommandSerializer + pagination_class = OpenWispPagination + + def get_queryset(self): + qs = super().get_queryset() + if not self.request.user.is_superuser: + # TODO: remove this filter once openwisp-users supports + # showing shared objects as read-only to non-superusers. + # See: https://github.com/openwisp/openwisp-controller/issues/1439 + qs = qs.filter(organization__isnull=False) + return qs + + +class BatchCommandDetailView(ProtectedAPIMixin, RetrieveAPIView): + queryset = BatchCommand.objects.annotate(device_count=Count("devices")) + serializer_class = BatchCommandDetailSerializer + + def get_queryset(self): + qs = super().get_queryset() + if not self.request.user.is_superuser: + # TODO: remove this filter once openwisp-users supports + # showing shared objects as read-only to non-superusers. + # See: https://github.com/openwisp/openwisp-controller/issues/1439 + qs = qs.filter(organization__isnull=False) + return qs + + class DeviceConnectionDetailView(BaseDeviceConnection, RetrieveUpdateDestroyAPIView): def get_object(self): queryset = self.filter_queryset(self.get_queryset()) @@ -149,6 +224,9 @@ def get_object(self): return obj +batch_command_execute_view = BatchCommandExecuteView.as_view() +batch_command_list_view = BatchCommandListView.as_view() +batch_command_detail_view = BatchCommandDetailView.as_view() command_list_create_view = CommandListCreateView.as_view() command_details_view = CommandDetailsView.as_view() credential_list_create_view = CredentialListCreateView.as_view() diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index 38b410778..dee7b5105 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -14,6 +14,7 @@ from swapper import get_model_name, load_model from openwisp_controller.config.base.base import BaseModel +from openwisp_users.mixins import ValidateOrgMixin from openwisp_utils.base import TimeStampedEditableModel from ...base import ShareableOrgMixinUniqueName @@ -29,7 +30,11 @@ ) from ..exceptions import NoWorkingDeviceConnectionError from ..signals import is_working_changed -from ..tasks import auto_add_credentials_to_devices, launch_command +from ..tasks import ( + auto_add_credentials_to_devices, + launch_batch_command, + launch_command, +) logger = logging.getLogger(__name__) @@ -459,6 +464,13 @@ class AbstractCommand(TimeStampedEditableModel): encoder=DjangoJSONEncoder, ) output = models.TextField(blank=True) + batch_command = models.ForeignKey( + get_model_name("connection", "BatchCommand"), + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="batch_commands", + ) class Meta: verbose_name = _("Command") @@ -549,6 +561,8 @@ def save(self, *args, **kwargs): output = super().save(*args, **kwargs) if adding: self._schedule_command() + if self.batch_command_id and self.status != "in-progress": + self.batch_command.calculate_and_update_status() return output def _save_without_resurrecting(self): @@ -711,3 +725,331 @@ def _enforce_not_custom(self): f"arguments property is not applicable in " f'command instance of type "{self.type}"' ) + + +class AbstractBatchCommand(ValidateOrgMixin, TimeStampedEditableModel): + STATUS_CHOICES = ( + ("idle", _("idle")), + ("in-progress", _("in progress")), + ("success", _("success")), + ("failed", _("failed")), + ) + # Mass commands targeting all devices in the system are valid and allowed. + # Only superusers can initiate system-wide mass commands. + organization = models.ForeignKey( + get_model_name("openwisp_users", "Organization"), + on_delete=models.CASCADE, + blank=True, + null=True, + ) + status = models.CharField( + max_length=12, choices=STATUS_CHOICES, default=STATUS_CHOICES[0][0] + ) + type = models.CharField( + max_length=16, + choices=(COMMAND_CHOICES if django.VERSION < (5, 0) else get_command_choices), + ) + input = JSONField(blank=True, null=True, encoder=DjangoJSONEncoder) + group = models.ForeignKey( + get_model_name("config", "DeviceGroup"), + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("device group"), + ) + location = models.ForeignKey( + get_model_name("geo", "Location"), + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("location"), + ) + label = models.CharField( + max_length=64, + verbose_name=_("label"), + help_text=_("A short label to identify this batch command."), + ) + notes = models.TextField( + blank=True, + default="", + verbose_name=_("notes"), + help_text=_("Optional notes about this batch command."), + ) + devices = models.ManyToManyField( + get_model_name("config", "Device"), + blank=True, + verbose_name=_("devices"), + ) + skipped_devices = models.JSONField( + blank=True, + null=True, + default=dict, + verbose_name=_("Skipped devices"), + help_text=_( + "Maps device UUIDs to validation error messages for devices " + "that were skipped during command creation." + ), + ) + + class Meta: + abstract = True + verbose_name = _("Mass command") + verbose_name_plural = _("Mass commands") + + def __str__(self): + return self.label + + @cached_property + def total_devices(self): + return self.batch_commands.count() + + @property + def successful(self): + return self.batch_commands.filter(status="success").count() + + @property + def failed(self): + return self.batch_commands.filter(status="failed").count() + + @staticmethod + def _validate_device_org(device, organization_id): + if organization_id and device.organization_id != organization_id: + raise ValidationError( + { + "devices": _( + "All devices must belong to the same " + "organization as the batch command." + ) + } + ) + + @classmethod + def _validate_devices_org(cls, devices, organization_id): + if not devices: + return + for device in devices: + cls._validate_device_org(device, organization_id) + + def _validate_org_relations(self): + if not self.organization_id: + return + self._validate_org_relation("group", field_error="group") + self._validate_org_relation("location", field_error="location") + if self.pk and self.devices.exists(): + org_mismatch = self.devices.exclude(organization=self.organization).exists() + if org_mismatch: + raise ValidationError( + { + "devices": _( + "All devices must belong to the same " + "organization as the batch command." + ) + } + ) + + def clean(self): + super().clean() + if not self.organization_id: + if self.group_id: + self.organization = self.group.organization + elif self.location_id: + self.organization = self.location.organization + self._validate_org_relations() + Command = load_model("connection", "Command") + allowed = dict( + Command.get_org_allowed_commands(organization_id=self.organization_id) + ) + if self.type not in allowed: + raise ValidationError( + { + "type": _( + '"{command}" command is not available for this organization' + ).format(command=self.type) + } + ) + try: + jsonschema.Draft4Validator(get_command_schema(self.type)).validate( + self.input + ) + except SchemaError as e: + raise ValidationError({"input": e.message}) + + def resolve_devices(self): + """ + Returns an iterator of devices targeted by this batch command, + resolved from explicit M2M devices or filtered by organization, + group, and location. Returns an empty iterator if no devices match. + """ + if self.pk and self.devices.exists(): + return self.devices.select_related("config").iterator() + Device = load_model("config", "Device") + qs = Device.objects.select_related("config") + if self.organization_id: + qs = qs.filter(organization=self.organization) + if self.group: + qs = qs.filter(group=self.group) + if self.location: + qs = qs.filter(devicelocation__location=self.location) + return qs.iterator() + + @classmethod + def execute(cls, **kwargs): + """ + Creates, validates, and persists the batch command, then schedules + execution via a background task. Raises ValidationError if no + devices match the criteria. + """ + devices_list = kwargs.pop("devices", None) + batch = cls(**kwargs) + with transaction.atomic(): + batch.full_clean() + batch.save() + if devices_list is not None: + batch.devices.set(devices_list) + batch._validate_org_relations() + else: + batch.devices.set(list(batch.resolve_devices())) + if not batch.devices.exists(): + raise ValidationError( + _("No devices match the specified criteria."), + ) + transaction.on_commit(lambda: launch_batch_command.delay(batch.pk)) + return batch + + @classmethod + def dry_run(cls, **kwargs): + """ + Returns the devices that would be targeted by this batch command + without executing it. Skips full validation when command type is + not provided case for GET request. + """ + devices_list = kwargs.pop("devices", None) + cmd_type = kwargs.pop("type", None) + kwargs.pop("label", None) + kwargs.pop("notes", None) + batch = cls(**kwargs) + if cmd_type: + batch.type = cmd_type + if not batch.label: + batch.label = "dry-run" + batch.full_clean() + else: + batch._validate_org_relations() + cls._validate_devices_org(devices_list, batch.organization_id) + if devices_list is not None: + return {"devices": list(devices_list)} + return {"devices": list(batch.resolve_devices())} + + def _clean_sensitive_info(self): + if self.type == "change_password": + self.input = {"password": "********"} + + def create_commands(self): + """ + Creates individual Command instances for each device targeted by + this batch command. Uses an atomic status transition to guard + against concurrent re-invocation. Devices that fail validation + are recorded in skipped_devices and logged. + """ + updated = self.__class__.objects.filter(pk=self.pk, status="idle").update( + status="in-progress" + ) + if not updated: + return + self.refresh_from_db(fields=["status"]) + Command = load_model("connection", "Command") + Device = load_model("config", "Device") + self.skipped_devices = {} + device_pks = [] + for device in self.resolve_devices(): + device_pks.append(device.pk) + command = Command( + device=device, + type=self.type, + input=self.input, + batch_command=self, + ) + try: + # Validate before the atomic block so errors like + # ValidationError don't create/rollback + command.full_clean() + with transaction.atomic(): + command.save() + except ValidationError as e: + self.skipped_devices[str(device.pk)] = ( + e.messages if hasattr(e, "messages") else [str(e)] + ) + logger.warning( + "Skipping device %s for batch %s: %s", + device.pk, + self.pk, + e, + ) + if not self.devices.exists() and device_pks: + self.devices.set(Device.objects.filter(pk__in=device_pks)) + if self.skipped_devices: + self.save(update_fields=["skipped_devices"]) + self.calculate_and_update_status() + self._clean_sensitive_info() + if self.type == "change_password": + self.save(update_fields=["input"]) + + def calculate_and_update_status(self): + """ + Calculate batch status based on individual command statuses and update if + changed. + - No commands exist: status set to "idle". + - Commands still running: status set to "in-progress". + - Some commands failed: status set to "failed". + - All commands completed successfully: status set to "success". + - Status unchanged: no database write performed. + """ + with transaction.atomic(): + batch = self.__class__.objects.select_for_update().get(pk=self.pk) + stats = batch.batch_commands.aggregate( + total_operations=models.Count("id"), + in_progress=models.Count( + models.Case( + models.When(status="in-progress", then=1), + output_field=models.IntegerField(), + ) + ), + completed=models.Count( + models.Case( + models.When(~models.Q(status="in-progress"), then=1), + output_field=models.IntegerField(), + ) + ), + successful=models.Count( + models.Case( + models.When(status="success", then=1), + output_field=models.IntegerField(), + ) + ), + failed=models.Count( + models.Case( + models.When(status="failed", then=1), + output_field=models.IntegerField(), + ) + ), + ) + if stats["total_operations"] == 0: + if batch.skipped_devices: + new_status = "failed" + else: + new_status = "idle" + elif stats["in_progress"] > 0: + new_status = "in-progress" + elif stats["failed"] > 0: + new_status = "failed" + elif ( + stats["successful"] > 0 + and stats["completed"] == stats["total_operations"] + ): + if batch.skipped_devices: + new_status = "failed" + else: + new_status = "success" + if batch.status != new_status: + batch.status = new_status + batch.save(update_fields=["status"]) diff --git a/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py b/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py new file mode 100644 index 000000000..8059c39c4 --- /dev/null +++ b/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py @@ -0,0 +1,181 @@ +# Generated by Django 5.2.15 on 2026-06-23 13:40 + +import uuid + +import django +import django.core.serializers.json +import django.db.migrations.operations.special +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +from django.conf import settings +from django.db import migrations, models + +import openwisp_controller.connection.commands + +from . import assign_batchcommand_permissions_to_groups + + +class Migration(migrations.Migration): + + dependencies = [ + ("connection", "0010_replace_jsonfield_with_django_builtin"), + ("openwisp_users", "0024_apikey"), + migrations.swappable_dependency(settings.CONFIG_DEVICEGROUP_MODEL), + migrations.swappable_dependency(settings.CONFIG_DEVICE_MODEL), + migrations.swappable_dependency(settings.GEO_LOCATION_MODEL), + ("config", "0036_device_group"), + ] + + operations = [ + migrations.CreateModel( + name="BatchCommand", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("idle", "idle"), + ("in-progress", "in progress"), + ("success", "success"), + ("failed", "failed"), + ], + default="idle", + max_length=12, + ), + ), + ( + "type", + models.CharField( + choices=( + openwisp_controller.connection.commands.COMMAND_CHOICES + if django.VERSION < (5, 0) + else openwisp_controller.connection.commands.get_command_choices # noqa: E501 + ), + max_length=16, + ), + ), + ( + "input", + models.JSONField( + blank=True, + encoder=django.core.serializers.json.DjangoJSONEncoder, + null=True, + ), + ), + ( + "skipped_devices", + models.JSONField( + blank=True, + default=dict, + help_text=( + "Maps device UUIDs to validation error messages for " + "devices that were skipped during command creation." + ), + null=True, + verbose_name="Skipped devices", + ), + ), + ( + "devices", + models.ManyToManyField( + blank=True, + to=settings.CONFIG_DEVICE_MODEL, + verbose_name="devices", + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.CONFIG_DEVICEGROUP_MODEL, + verbose_name="device group", + ), + ), + ( + "location", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.GEO_LOCATION_MODEL, + verbose_name="location", + ), + ), + ( + "label", + models.CharField( + help_text=("A short label to identify this batch command."), + max_length=64, + verbose_name="label", + ), + ), + ( + "notes", + models.TextField( + blank=True, + default="", + verbose_name="notes", + help_text="Optional notes about this batch command.", + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="openwisp_users.organization", + ), + ), + ], + options={ + "verbose_name": "Mass command", + "verbose_name_plural": "Mass commands", + "abstract": False, + "swappable": "CONNECTION_BATCHCOMMAND_MODEL", + }, + ), + migrations.AddField( + model_name="command", + name="batch_command", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="batch_commands", + to=settings.CONNECTION_BATCHCOMMAND_MODEL, + ), + ), + migrations.RunPython( + code=assign_batchcommand_permissions_to_groups, + reverse_code=django.db.migrations.operations.special.RunPython.noop, + ), + ] diff --git a/openwisp_controller/connection/migrations/__init__.py b/openwisp_controller/connection/migrations/__init__.py index 5b5853d2b..67a2c20e7 100644 --- a/openwisp_controller/connection/migrations/__init__.py +++ b/openwisp_controller/connection/migrations/__init__.py @@ -60,3 +60,30 @@ def assign_command_permissions_to_groups(apps, schema_editor): admin.permissions.add( Permission.objects.get(codename="{}_{}".format(operation, "command")).pk ) + + +def assign_batchcommand_permissions_to_groups(apps, schema_editor): + create_default_permissions(apps, schema_editor) + admin_operations = ["add", "change", "delete", "view"] + operator_operations = ["add", "view"] + Group = get_swapped_model(apps, "openwisp_users", "Group") + + try: + admin = Group.objects.get(name="Administrator") + operator = Group.objects.get(name="Operator") + except Group.DoesNotExist: + return + + for operation in operator_operations: + permission = Permission.objects.get( + codename="{}_{}".format(operation, "batchcommand") + ) + admin.permissions.add(permission.pk) + operator.permissions.add(permission.pk) + + for operation in admin_operations: + admin.permissions.add( + Permission.objects.get( + codename="{}_{}".format(operation, "batchcommand") + ).pk + ) diff --git a/openwisp_controller/connection/models.py b/openwisp_controller/connection/models.py index 39d485b57..533ad6c4d 100644 --- a/openwisp_controller/connection/models.py +++ b/openwisp_controller/connection/models.py @@ -1,6 +1,11 @@ import swapper -from .base.models import AbstractCommand, AbstractCredentials, AbstractDeviceConnection +from .base.models import ( + AbstractBatchCommand, + AbstractCommand, + AbstractCredentials, + AbstractDeviceConnection, +) class Credentials(AbstractCredentials): @@ -19,3 +24,9 @@ class Command(AbstractCommand): class Meta(AbstractCommand.Meta): abstract = False swappable = swapper.swappable_setting("connection", "Command") + + +class BatchCommand(AbstractBatchCommand): + class Meta(AbstractBatchCommand.Meta): + abstract = False + swappable = swapper.swappable_setting("connection", "BatchCommand") diff --git a/openwisp_controller/connection/tasks.py b/openwisp_controller/connection/tasks.py index d6ea2828b..6f61d4d0d 100644 --- a/openwisp_controller/connection/tasks.py +++ b/openwisp_controller/connection/tasks.py @@ -98,6 +98,28 @@ def launch_command(command_id): command._save_without_resurrecting() +@shared_task(bind=True) +def launch_batch_command(self, batch_id): + BatchCommand = load_model("connection", "BatchCommand") + try: + batch = BatchCommand.objects.get(pk=batch_id) + except BatchCommand.DoesNotExist: + logger.warning(f"The BatchCommand object with id {batch_id} has been deleted") + return + try: + batch.create_commands() + except Exception: + batch._clean_sensitive_info() + batch.status = "failed" + update_fields = ["status"] + if batch.type == "change_password": + update_fields.append("input") + batch.save(update_fields=update_fields) + logger.exception( + f"An exception was raised while executing batch command {batch_id}" + ) + + @shared_task(soft_time_limit=3600) def auto_add_credentials_to_devices(credential_id, organization_id): Credentials = load_model("connection", "Credentials") diff --git a/openwisp_controller/connection/tests/pytest.py b/openwisp_controller/connection/tests/pytest.py index 220604cef..d7f7f5659 100644 --- a/openwisp_controller/connection/tests/pytest.py +++ b/openwisp_controller/connection/tests/pytest.py @@ -65,6 +65,7 @@ def _get_expected_response(self, command): "output": command.output, "device": str(command.device_id), "connection": str(command.connection_id), + "batch_command": None, }, } diff --git a/openwisp_controller/connection/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py index 6e3a827ab..ef46278f2 100644 --- a/openwisp_controller/connection/tests/test_api.py +++ b/openwisp_controller/connection/tests/test_api.py @@ -3,7 +3,7 @@ from unittest.mock import patch from django.contrib.auth.models import Permission -from django.test import TestCase +from django.test import TestCase, TransactionTestCase from django.urls import reverse from django.utils.timezone import now, timedelta from packaging.version import parse as parse_version @@ -15,15 +15,19 @@ from openwisp_users.tests.test_api import AuthenticationMixin from .. import settings as app_settings -from ..api.views import CommandListCreateView +from ..api.views import BatchCommandListView, CommandListCreateView from ..commands import ORGANIZATION_ENABLED_COMMANDS from .utils import CreateCommandMixin, CreateConnectionsMixin Command = load_model("connection", "Command") DeviceConnection = load_model("connection", "DeviceConnection") +BatchCommand = load_model("connection", "BatchCommand") command_qs = Command.objects.order_by("-created") OrganizationUser = load_model("openwisp_users", "OrganizationUser") Group = load_model("openwisp_users", "Group") +DeviceGroup = load_model("config", "DeviceGroup") +Location = load_model("geo", "Location") +DeviceLocation = load_model("geo", "DeviceLocation") class TestCommandsAPI(TestCase, AuthenticationMixin, CreateCommandMixin): @@ -860,3 +864,1508 @@ def test_deviceconnection_unauthenticated_user(self): "delete": 401, }, ) + + +class TestBatchCommandsAPI( + TestAdminMixin, AuthenticationMixin, TestCase, CreateConnectionsMixin +): + url_namespace = "connection_api" + + def setUp(self): + super().setUp() + self._login() + + def test_batch_command_list(self): + org = self._get_org() + url = reverse("connection_api:batch_command_list") + for _ in range(3): + self._create_batch_command(organization=org) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 3) + self.assertEqual(len(response.data["results"]), 3) + created_list = [cmd["created"] for cmd in response.data["results"]] + sorted_created = sorted(created_list, reverse=True) + self.assertEqual(created_list, sorted_created) + result = response.data["results"][0] + self.assertIn("id", result) + self.assertIn("status", result) + self.assertIn("type", result) + self.assertIn("input", result) + self.assertIn("device_count", result) + self.assertIn("created", result) + self.assertEqual(result["device_count"], 0) + + with patch.object(BatchCommandListView, "pagination_page_size", 2, create=True): + with self.subTest("pagination page 1"): + for _ in range(2): + self._create_batch_command(organization=org) + response = self.client.get(url + "?page=1") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 2) + + with self.subTest("pagination page 2"): + response = self.client.get(url + "?page=2") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 2) + + with self.subTest("pagination page 3 (partial)"): + response = self.client.get(url + "?page=3") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + + def test_batch_command_detail(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + batch = self._create_batch_command(organization=org, devices=[device]) + url = reverse("connection_api:batch_command_detail", args=[batch.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], str(batch.pk)) + self.assertEqual(response.data["status"], batch.status) + self.assertEqual(response.data["type"], batch.type) + self.assertEqual(response.data["input"], batch.input) + self.assertIn("devices", response.data) + self.assertEqual(response.data["devices"], [str(device.pk)]) + self.assertEqual(response.data["device_count"], 1) + + def test_batch_command_dry_run_endpoint(self): + org = self._get_org() + device1 = self._create_device( + name="dryf-dev1", + mac_address="00:11:22:33:44:d1", + organization=org, + ) + self._create_config(device=device1) + device2 = self._create_device( + name="dryf-dev2", + mac_address="00:11:22:33:44:d2", + organization=org, + ) + self._create_config(device=device2) + group = DeviceGroup.objects.create(name="dryf-group", organization=org) + device1.group = group + device1.save() + location = Location.objects.create( + name="dryf-loc", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device2, location=location) + + base_url = reverse("connection_api:batch_command_execute") + + with self.subTest("dry run with explicit devices"): + url = "{0}?organization={1}&devices={2}".format( + base_url, + str(org.pk), + str(device1.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["devices"], [str(device1.pk)]) + + with self.subTest("dry run with group"): + url = "{0}?organization={1}&group={2}".format( + base_url, + str(org.pk), + str(group.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertNotIn(str(device2.pk), response.data["devices"]) + + with self.subTest("dry run with location"): + url = "{0}?organization={1}&location={2}".format( + base_url, + str(org.pk), + str(location.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device2.pk), response.data["devices"]) + self.assertNotIn(str(device1.pk), response.data["devices"]) + + with self.subTest("dry run with group and location"): + DeviceLocation.objects.create(content_object=device1, location=location) + url = "{0}?organization={1}&group={2}&location={3}".format( + base_url, + str(org.pk), + str(group.pk), + str(location.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertNotIn(str(device2.pk), response.data["devices"]) + + with self.subTest("dry run org-wide"): + url = "{0}?organization={1}".format(base_url, str(org.pk)) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertIn(str(device2.pk), response.data["devices"]) + + with self.subTest("dry run with type and input"): + url = ( + "{0}?organization={1}&type=custom" + "&input=%7B%22command%22%3A%22uptime%22%7D" + ).format(base_url, str(org.pk)) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertIn(str(device2.pk), response.data["devices"]) + + def test_batch_command_endpoints_no_of_queries(self): + with self.subTest("list queries"): + org = self._get_org() + devices = [] + for i in range(2): + d = self._create_device( + name=f"q-dev-{i}", + mac_address=f"00:11:22:33:44:{i:02x}", + organization=org, + ) + self._create_config(device=d) + devices.append(d) + self._create_batch_command(organization=org, devices=devices) + url = reverse("connection_api:batch_command_list") + with self.assertNumQueries(3): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + with self.subTest("detail queries"): + url = reverse( + "connection_api:batch_command_detail", + args=[response.data["results"][0]["id"]], + ) + with self.assertNumQueries(3): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + with self.subTest("execute queries"): + org = self._get_org() + devices = [] + for i in range(3): + d = self._create_device( + name=f"q-exec-{i}", + mac_address=f"00:11:22:33:44:{i + 0x10:02x}", + organization=org, + ) + self._create_config(device=d) + devices.append(d) + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(d.pk) for d in devices], + } + url = reverse("connection_api:batch_command_execute") + with self.assertNumQueries(16): + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + self.assertEqual(batch.devices.count(), 3) + self.assertCountEqual( + batch.devices.values_list("pk", flat=True), + [d.pk for d in devices], + ) + + with self.subTest("execute queries with group"): + org = self._get_org() + group = DeviceGroup.objects.create(name="q-exec-group", organization=org) + devices = [] + for i in range(3): + d = self._create_device( + name=f"q-exec-grp-{i}", + mac_address=f"00:11:22:33:44:{0x30 + i:02x}", + organization=org, + ) + self._create_config(device=d) + d.group = group + d.save() + devices.append(d) + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "group": str(group.pk), + } + url = reverse("connection_api:batch_command_execute") + with self.assertNumQueries(15): + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + + with self.subTest("execute queries org-wide"): + org = self._get_org() + devices = [] + for i in range(3): + d = self._create_device( + name=f"q-exec-all-{i}", + mac_address=f"00:11:22:33:44:{0x40 + i:02x}", + organization=org, + ) + self._create_config(device=d) + devices.append(d) + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + url = reverse("connection_api:batch_command_execute") + with self.assertNumQueries(13): + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + + def test_batch_command_execute_org_has_no_devices(self): + org = self._get_org() + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + url = reverse("connection_api:batch_command_execute") + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + ["No devices match the specified criteria."], + ) + + def test_batch_command_execute_disallowed_type(self): + org = self._get_org() + url = reverse("connection_api:batch_command_execute") + with patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(org.pk): ("reboot",)}, + ): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + '"custom" command is not available for this organization', + response.data["type"][0], + ) + + def test_batch_command_no_org_only_allowed_to_superuser(self): + org = self._get_org() + self.client.logout() + operator = self._create_operator(organizations=[org]) + add_perm = Permission.objects.get(codename="add_batchcommand") + operator.user_permissions.add(add_perm) + self.client.force_login(operator) + payload = { + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + url = reverse("connection_api:batch_command_execute") + with self.subTest("POST execute without org"): + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + "Only superusers", + str(response.data), + ) + + with self.subTest("GET dry-run without org"): + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + self.assertIn( + "Only superusers", + str(response.data), + ) + + def test_superuser_batch_command_execute_without_org(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device1 = self._create_device( + name="super-org-dev1", + mac_address="00:11:22:33:44:81", + organization=org, + ) + self._create_config(device=device1) + device2 = self._create_device( + name="super-org-dev2", + mac_address="00:11:22:33:44:82", + organization=org2, + ) + self._create_config(device=device2) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("execute targets all devices globally"): + response = self.client.post( + url, + data=json.dumps( + { + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + self.assertEqual(batch.devices.count(), 2) + + with self.subTest("execute with explicit devices"): + response = self.client.post( + url, + data=json.dumps( + { + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device1.pk), str(device2.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + self.assertEqual(batch.devices.count(), 2) + + with self.subTest("dry-run targets all devices"): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertIn(str(device2.pk), response.data["devices"]) + + with self.subTest("dry-run with explicit devices"): + response = self.client.get( + f"{url}?devices={str(device1.pk)}&devices={str(device2.pk)}" + ) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertIn(str(device2.pk), response.data["devices"]) + + def test_superuser_org_auto_set_from_group_and_location(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("from group"): + group = DeviceGroup.objects.create(name="infer-group", organization=org) + group.device_set.add(device) + response = self.client.post( + url, + data=json.dumps( + { + "type": "custom", + "input": {"command": "echo test"}, + "label": "infer-group", + "group": str(group.pk), + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + self.assertEqual(batch.organization, org) + self.assertEqual(batch.devices.count(), 1) + self.assertIn(device.pk, batch.devices.values_list("pk", flat=True)) + + with self.subTest("from location"): + location = Location.objects.create( + name="infer-location", + organization=org, + geometry="POINT (12.0 44.0)", + ) + DeviceLocation.objects.create(content_object=device, location=location) + response = self.client.post( + url, + data=json.dumps( + { + "type": "custom", + "input": {"command": "echo test"}, + "label": "infer-location", + "location": str(location.pk), + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + self.assertEqual(batch.organization, org) + self.assertEqual(batch.devices.count(), 1) + self.assertIn(device.pk, batch.devices.values_list("pk", flat=True)) + + def test_batch_command_operator_endpoints_on_managed_org(self): + org = self._get_org() + self._create_credentials(name="op-cred", organization=org) + device = self._create_device( + name="op-dev", + mac_address="00:11:22:33:44:01", + organization=org, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + self.client.logout() + operator = self._create_operator(organizations=[org]) + operator.user_permissions.add( + Permission.objects.get(codename="add_batchcommand"), + Permission.objects.get(codename="view_batchcommand"), + ) + self.client.force_login(operator) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("execute"): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + with self.subTest("dry-run"): + response = self.client.get( + url, + data={"organization": str(org.pk)}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("devices", response.data) + + with self.subTest("execute org-wide"): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + def test_batch_command_administrator_endpoints_on_managed_org(self): + org = self._get_org() + self._create_credentials(name="admin-cred", organization=org) + device = self._create_device( + name="admin-dev", + mac_address="00:11:22:33:44:02", + organization=org, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + self.client.logout() + administrator = self._create_administrator(organizations=[org]) + self.client.force_login(administrator) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("execute"): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + with self.subTest("dry-run"): + response = self.client.get( + url, + data={"organization": str(org.pk)}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("devices", response.data) + + with self.subTest("execute org-wide"): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + def test_batch_command_operator_endpoints_on_non_managed_org(self): + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + self._create_credentials(name="op-nm-cred", organization=org1) + device = self._create_device( + name="op-nm-dev", + mac_address="00:11:22:33:44:03", + organization=org1, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + self.client.logout() + operator = self._create_operator(organizations=[org1]) + operator.user_permissions.add( + Permission.objects.get(codename="add_batchcommand"), + Permission.objects.get(codename="view_batchcommand"), + ) + self.client.force_login(operator) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("execute"): + payload = { + "organization": str(org2.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("dry-run"): + response = self.client.get( + url, + data={"organization": str(org2.pk)}, + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("execute org-wide"): + payload = { + "organization": str(org2.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_batch_command_administrator_endpoints_on_non_managed_org(self): + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + self._create_credentials(name="admin-nm-cred", organization=org1) + device = self._create_device( + name="admin-nm-dev", + mac_address="00:11:22:33:44:04", + organization=org1, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + self.client.logout() + administrator = self._create_administrator(organizations=[org1]) + self.client.force_login(administrator) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("execute"): + payload = { + "organization": str(org2.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("dry-run"): + response = self.client.get( + url, + data={"organization": str(org2.pk)}, + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("execute org-wide"): + payload = { + "organization": str(org2.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_batch_command_endpoints_organization_scoped(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + batch_org1 = self._create_batch_command(organization=org) + batch_org2 = self._create_batch_command(organization=org2) + self.client.logout() + operator = self._create_operator(organizations=[org]) + view_perm = Permission.objects.get(codename="view_batchcommand") + operator.user_permissions.add(view_perm) + self.client.force_login(operator) + + with self.subTest("list scoped to own org"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + with self.subTest("detail cross-org"): + url = reverse( + "connection_api:batch_command_detail", + args=[batch_org2.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + with self.subTest("detail own org"): + url = reverse( + "connection_api:batch_command_detail", + args=[batch_org1.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_shared_batch_command_hidden_from_non_superuser(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device(organization=org2) + batch = self._create_batch_command(organization=None, devices=[device_org2]) + self.client.logout() + operator = self._create_operator(organizations=[org]) + operator.user_permissions.add( + Permission.objects.get(codename="view_batchcommand") + ) + self.client.force_login(operator) + + with self.subTest("list hides org-wide batch"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + + with self.subTest("detail returns 404 for org-wide batch"): + url = reverse("connection_api:batch_command_detail", args=[batch.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + with self.subTest("superuser still sees org-wide batch"): + self.client.logout() + self._login() + url = reverse("connection_api:batch_command_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + def test_batch_command_endpoints_unauthorized(self): + self.client.logout() + execute_url = reverse("connection_api:batch_command_execute") + + with self.subTest("List"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + with self.subTest("Detail"): + url = reverse( + "connection_api:batch_command_detail", + args=[uuid.uuid4()], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + with self.subTest("Dry run"): + response = self.client.get(execute_url) + self.assertEqual(response.status_code, 401) + + with self.subTest("Execute"): + response = self.client.post( + execute_url, + data=json.dumps({"type": "custom"}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 401) + + def test_batch_command_bearer_authentication(self): + self.client.logout() + org = self._get_org() + batch = self._create_batch_command(organization=org) + device = self._create_device( + name="bearer-dev", + mac_address="00:11:22:33:44:be", + organization=org, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + token = self._obtain_auth_token(username="admin", password="tester") + auth = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + + with self.subTest("list"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url, **auth) + self.assertEqual(response.status_code, 200) + self.assertIn("results", response.data) + + with self.subTest("detail"): + url = reverse( + "connection_api:batch_command_detail", + args=[batch.pk], + ) + response = self.client.get(url, **auth) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], str(batch.pk)) + + with self.subTest("dry-run"): + url = reverse("connection_api:batch_command_execute") + response = self.client.get( + url, + data={"organization": str(org.pk)}, + **auth, + ) + self.assertEqual(response.status_code, 200) + + with self.subTest("execute"): + url = reverse("connection_api:batch_command_execute") + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + **auth, + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + def test_batch_command_detail_404(self): + org = self._get_org() + self._create_batch_command(organization=org) + url = reverse( + "connection_api:batch_command_detail", + args=[uuid.uuid4()], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_batch_command_endpoints_operator_different_org(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + batch_org1 = self._create_batch_command(organization=org) + self.client.logout() + operator = self._create_operator(organizations=[org2]) + self.client.force_login(operator) + + with self.subTest("list"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + + with self.subTest("detail"): + url = reverse( + "connection_api:batch_command_detail", + args=[batch_org1.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + with self.subTest("dry-run"): + url = reverse("connection_api:batch_command_execute") + response = self.client.get( + url, + data={"organization": str(org.pk)}, + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("execute"): + url = reverse("connection_api:batch_command_execute") + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_batch_command_execute_org_mismatched_data_provided(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device( + name="api-mm-dev", + mac_address="00:11:22:33:44:88", + organization=org2, + ) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("device org mismatch"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device_org2.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "devices": [ + "All devices must belong to the same " + "organization as the batch command." + ] + }, + ) + + with self.subTest("group org mismatch"): + group_org2 = DeviceGroup.objects.create( + name="api-mm-group", + organization=org2, + ) + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "group": str(group_org2.pk), + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "group": [ + ( + "Please ensure that the organization of this Mass command " + "and the organization of the related Device Group match." + ) + ] + }, + ) + + with self.subTest("location org mismatch"): + location_org2 = Location.objects.create( + name="api-mm-loc", + type="indoor", + organization=org2, + ) + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "location": str(location_org2.pk), + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "location": [ + ( + "Please ensure that the organization of this Mass command " + "and the organization of the related location match." + ) + ] + }, + ) + + def test_batch_command_dry_run_org_mismatched_data_provided(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device( + name="dry-mm-dev", + mac_address="00:11:22:33:44:78", + organization=org2, + ) + base_url = reverse("connection_api:batch_command_execute") + + with self.subTest("device org mismatch"): + url = "{0}?organization={1}&devices={2}".format( + base_url, + str(org.pk), + str(device_org2.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "devices": [ + "All devices must belong to the same " + "organization as the batch command." + ] + }, + ) + + with self.subTest("group org mismatch"): + group_org2 = DeviceGroup.objects.create( + name="dry-mm-group", + organization=org2, + ) + url = "{0}?organization={1}&group={2}".format( + base_url, + str(org.pk), + str(group_org2.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "group": [ + ( + "Please ensure that the organization of this Mass command " + "and the organization of the related Device Group match." + ) + ] + }, + ) + + with self.subTest("location org mismatch"): + location_org2 = Location.objects.create( + name="dry-mm-loc", + type="indoor", + organization=org2, + ) + url = "{0}?organization={1}&location={2}".format( + base_url, + str(org.pk), + str(location_org2.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "location": [ + ( + "Please ensure that the organization of this Mass command " + "and the organization of the related location match." + ) + ] + }, + ) + + +class TestBatchCommandsAPITransaction( + TestAdminMixin, AuthenticationMixin, TransactionTestCase, CreateConnectionsMixin +): + url_namespace = "connection_api" + + def setUp(self): + super().setUp() + self._login() + patcher = patch("openwisp_controller.connection.tasks.launch_command.delay") + self.addCleanup(patcher.stop) + patcher.start() + + def test_batch_command_execute(self): + org = self._get_org() + cred = self._create_credentials(name="exec-cred", organization=org) + device1 = self._create_device( + name="exec-dev1", + mac_address="00:11:22:33:44:e1", + organization=org, + ) + self._create_config(device=device1) + self._create_device_connection(device=device1, credentials=cred) + device2 = self._create_device( + name="exec-dev2", + mac_address="00:11:22:33:44:e2", + organization=org, + ) + self._create_config(device=device2) + self._create_device_connection(device=device2, credentials=cred) + group = DeviceGroup.objects.create(name="exec-group", organization=org) + device1.group = group + device1.save() + location = Location.objects.create( + name="exec-loc", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device2, location=location) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("execute with explicit devices"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device1.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + command = Command.objects.get(batch_command=batch, device=device1) + self.assertEqual(command.device.pk, device1.pk) + self.assertEqual(batch.skipped_devices, {}) + + with self.subTest("execute with group"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "group": str(group.pk), + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + command = Command.objects.get(batch_command=batch, device=device1) + self.assertEqual(command.device.pk, device1.pk) + self.assertEqual(batch.skipped_devices, {}) + + with self.subTest("execute with location"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "location": str(location.pk), + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + command = Command.objects.get(batch_command=batch, device=device2) + self.assertEqual(command.device.pk, device2.pk) + self.assertEqual(batch.skipped_devices, {}) + + with self.subTest("execute with group and location"): + DeviceLocation.objects.create(content_object=device1, location=location) + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "group": str(group.pk), + "location": str(location.pk), + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + command = Command.objects.get(batch_command=batch, device=device1) + self.assertEqual(command.device.pk, device1.pk) + self.assertEqual(batch.skipped_devices, {}) + + with self.subTest("execute org-wide"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + + self.assertEqual( + Command.objects.filter(batch_command=batch).count(), + 2, + ) + self.assertEqual(batch.skipped_devices, {}) + + with self.subTest("execute with empty devices list"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + ["No devices match the specified criteria."], + ) + + with self.subTest("execute org-wide for superuser"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + self.assertEqual( + Command.objects.filter(batch_command=batch).count(), + 2, + ) + self.assertEqual(batch.skipped_devices, {}) + + with self.subTest("execute with empty label"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "", + "devices": [str(device1.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("execute with label exceeding max_length"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "a" * 65, + "devices": [str(device1.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("execute custom type with empty input"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {}, + "label": "test-label", + "devices": [str(device1.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("execute with invalid type"): + response = self.client.post( + url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "nonexistent", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device1.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_batch_command_change_password_input_is_not_exposed(self): + org = self._get_org() + cred = self._create_credentials(name="batch-pwd-cred", organization=org) + device = self._create_device( + name="batch-pwd-dev", + mac_address="00:11:22:33:44:91", + organization=org, + ) + self._create_config(device=device) + self._create_device_connection(device=device, credentials=cred) + password = "SuperSecret123" + response = self.client.post( + reverse("connection_api:batch_command_execute"), + data=json.dumps( + { + "organization": str(org.pk), + "type": "change_password", + "input": { + "password": password, + "confirm_password": password, + }, + "label": "change password", + "devices": [str(device.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + self.assertNotIn(password, json.dumps(batch.input)) + list_response = self.client.get(reverse("connection_api:batch_command_list")) + self.assertEqual(list_response.status_code, 200) + self.assertNotIn(password, json.dumps(list_response.data)) + detail_response = self.client.get( + reverse("connection_api:batch_command_detail", args=[batch.pk]) + ) + self.assertEqual(detail_response.status_code, 200) + self.assertNotIn(password, json.dumps(detail_response.data)) + + def test_batch_command_execute_skipped_devices(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_a = self._create_device( + name="device-a", + mac_address="00:11:22:33:44:aa", + organization=org, + ) + self._create_config(device=device_a) + self._create_device_connection(device=device_a) + device_b = self._create_device( + name="device-b", + mac_address="00:11:22:33:44:bb", + organization=org2, + ) + self._create_config(device=device_b) + self._create_device_connection(device=device_b) + execute_url = reverse("connection_api:batch_command_execute") + + with patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(org2.pk): ("reboot",)}, + ): + payload = { + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device_a.pk), str(device_b.pk)], + } + response = self.client.post( + execute_url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + + self.assertIn(str(device_b.pk), batch.skipped_devices) + self.assertIn( + '"custom" command is not available for this organization', + batch.skipped_devices[str(device_b.pk)][0], + ) + command_qs = Command.objects.filter(batch_command=batch) + self.assertTrue(command_qs.filter(device=device_a).exists()) + self.assertFalse(command_qs.filter(device=device_b).exists()) + url = reverse( + "connection_api:device_command_list", + args=[device_a.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + cmd_data = response.data["results"][0] + self.assertIn("type", cmd_data) + self.assertIn("input", cmd_data) + + with self.subTest("superuser cross-org list/detail response"): + list_resp = self.client.get( + reverse("connection_api:batch_command_list"), + ) + self.assertEqual(list_resp.status_code, 200) + self.assertEqual( + list_resp.data["results"][0]["device_count"], + 2, + ) + detail_resp = self.client.get( + reverse( + "connection_api:batch_command_detail", + args=[batch.pk], + ), + ) + self.assertEqual(detail_resp.status_code, 200) + self.assertEqual(detail_resp.data["device_count"], 2) + self.assertEqual(len(detail_resp.data["devices"]), 2) + + with self.subTest("skipped: no credentials"): + device = self._create_device( + name="skip-nc-dev", + mac_address="00:11:22:33:44:a1", + organization=org, + ) + self._create_config(device=device) + response = self.client.post( + execute_url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertIn( + "Device has no credentials assigned", + batch.skipped_devices[str(device.pk)][0], + ) + detail_url = reverse( + "connection_api:batch_command_detail", + args=[batch.pk], + ) + detail_response = self.client.get(detail_url) + self.assertEqual(detail_response.status_code, 200) + self.assertIn(str(device.pk), detail_response.data["skipped_devices"]) + + with self.subTest("skipped: deactivated device"): + device = self._create_device( + name="skip-dd-dev", + mac_address="00:11:22:33:44:a2", + organization=org, + ) + self._create_config(device=device) + dd_cred = self._create_credentials( + name="skip-dd-cred", + organization=org, + ) + self._create_device_connection(device=device, credentials=dd_cred) + device.deactivate() + response = self.client.post( + execute_url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertIn( + "Device is deactivated", + batch.skipped_devices[str(device.pk)][0], + ) + detail_url = reverse( + "connection_api:batch_command_detail", + args=[batch.pk], + ) + detail_response = self.client.get(detail_url) + self.assertEqual(detail_response.status_code, 200) + self.assertIn(str(device.pk), detail_response.data["skipped_devices"]) diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index e3ed6bb97..fc45ff2d6 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -32,6 +32,10 @@ Group = load_model("openwisp_users", "Group") Organization = load_model("openwisp_users", "Organization") Command = load_model("connection", "Command") +BatchCommand = load_model("connection", "BatchCommand") +DeviceGroup = load_model("config", "DeviceGroup") +Location = load_model("geo", "Location") +DeviceLocation = load_model("geo", "DeviceLocation") _connect_path = "paramiko.SSHClient.connect" _exec_command_path = "paramiko.SSHClient.exec_command" @@ -460,14 +464,14 @@ def test_operator_group_permissions(self): permissions = group.permissions.filter( content_type__app_label=f"{self.app_label}" ) - self.assertEqual(permissions.count(), 6) + self.assertEqual(permissions.count(), 8) def test_administrator_group_permissions(self): group = Group.objects.get(name="Administrator") permissions = group.permissions.filter( content_type__app_label=f"{self.app_label}" ) - self.assertEqual(permissions.count(), 12) + self.assertEqual(permissions.count(), 16) def test_device_connection_set_connector(self): dc = self._create_device_connection() @@ -968,6 +972,795 @@ def test_command_multiple_connections(self, connect_mocked): command.refresh_from_db() self.assertIn(command.connection, [dc1, dc2]) + def test_batch_command_str(self): + org = self._get_org() + batch = self._create_batch_command(organization=org) + self.assertEqual(str(batch), "test-label") + + def test_batch_command_total_devices_successful_failed(self): + org = self._get_org() + device1 = self._create_device(organization=org) + self._create_config(device=device1) + dc1 = self._create_device_connection(device=device1) + device2 = self._create_device( + name="device2", + mac_address="00:11:22:33:44:02", + organization=org, + ) + self._create_config(device=device2) + dc2 = self._create_device_connection( + device=device2, + credentials=self._create_credentials( + name="Test credentials 2", + organization=org, + ), + ) + batch = self._create_batch_command(organization=org) + Command.objects.create( + batch_command=batch, + device=device1, + connection=dc1, + type=batch.type, + input={"command": "echo test"}, + status="success", + ) + Command.objects.create( + batch_command=batch, + device=device2, + connection=dc2, + type=batch.type, + input={"command": "echo test"}, + status="failed", + ) + self.assertEqual(batch.total_devices, 2) + self.assertEqual(batch.successful, 1) + self.assertEqual(batch.failed, 1) + self.assertEqual( + batch.batch_commands.filter(status="success").first().device, device1 + ) + self.assertEqual( + batch.batch_commands.filter(status="failed").first().device, device2 + ) + self.assertFalse( + batch.batch_commands.filter(status="success", device=device2).exists() + ) + self.assertFalse( + batch.batch_commands.filter(status="failed", device=device1).exists() + ) + + def test_batch_command_clean_validation(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device(organization=org2) + + with self.subTest("devices from different org"): + batch = self._create_batch_command(organization=org) + batch.devices.add(device_org2) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("devices", ctx.exception.message_dict) + self.assertIn( + "must belong to the same organization", + ctx.exception.message_dict["devices"][0], + ) + + with self.subTest("invalid command type for org"): + with mock.patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(org.pk): ("reboot",)}, + ): + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("type", ctx.exception.message_dict) + self.assertIn( + "not available for this organization", + ctx.exception.message_dict["type"][0], + ) + + with self.subTest("invalid JSON schema"): + batch = BatchCommand( + organization=org, + type="change_password", + input="not_an_object", + label="test-label", + ) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("input", ctx.exception.message_dict) + + with self.subTest("group org mismatch"): + group = DeviceGroup.objects.create(name="test-group", organization=org2) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + group=group, + ) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("group", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Mass command " + "and the organization of the related Device Group match", + ctx.exception.message_dict["group"][0], + ) + + with self.subTest("location org mismatch"): + location = Location.objects.create( + name="test-location", + type="indoor", + organization=org2, + ) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + location=location, + ) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("location", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Mass command " + "and the organization of the related location match", + ctx.exception.message_dict["location"][0], + ) + + def test_batch_command_create_commands_deactivated_device(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + + with self.subTest("deactivating device does not block create_commands"): + device._is_deactivated = True + device.save(update_fields=["_is_deactivated"]) + device.config.set_status_deactivating() + device = Device.objects.get(pk=device.pk) + batch = self._create_batch_command(organization=org) + batch.devices.add(device) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.skipped_devices, {}) + + with self.subTest("fully deactivated device skipped in create_commands"): + device.config.set_status_deactivated() + device = Device.objects.get(pk=device.pk) + batch = self._create_batch_command(organization=org) + batch.devices.add(device) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 0) + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertIn( + "Device is deactivated", + batch.skipped_devices[str(device.pk)][0], + ) + + def test_batch_command_create_commands_no_credentials(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + batch = self._create_batch_command( + organization=org, + devices=[device], + ) + batch.create_commands() + batch.refresh_from_db() + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertIn( + "Device has no credentials assigned", + batch.skipped_devices[str(device.pk)][0], + ) + + def test_batch_command_create_commands_skip_scenarios(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + + with self.subTest("org command not allowed"): + device_a = self._create_device( + name="device-a", + mac_address="00:11:22:33:44:aa", + organization=org, + ) + self._create_config(device=device_a) + self._create_device_connection(device=device_a) + device_b = self._create_device( + name="device-b", + mac_address="00:11:22:33:44:bb", + organization=org2, + ) + self._create_config(device=device_b) + self._create_device_connection(device=device_b) + with mock.patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(org2.pk): ("reboot",)}, + ): + batch = self._create_batch_command( + organization=org, + devices=[device_a, device_b], + ) + batch.create_commands() + batch.refresh_from_db() + self.assertIn(str(device_b.pk), batch.skipped_devices) + self.assertIn( + '"custom" command is not available for this organization', + batch.skipped_devices[str(device_b.pk)][0], + ) + db_batch = BatchCommand.objects.get(pk=batch.pk) + self.assertEqual(batch.skipped_devices, db_batch.skipped_devices) + command_qs = Command.objects.filter(batch_command=batch) + self.assertTrue(command_qs.filter(device=device_a).exists()) + self.assertFalse(command_qs.filter(device=device_b).exists()) + + with self.subTest("mixed skip scenario"): + device_ok = self._create_device( + name="device-ok", + mac_address="00:11:22:33:44:01", + organization=org, + ) + self._create_config(device=device_ok) + ok_cred = self._create_credentials(name="device-ok-cred", organization=org) + self._create_device_connection(device=device_ok, credentials=ok_cred) + device_no_creds = self._create_device( + name="device-no-creds", + mac_address="00:11:22:33:44:02", + organization=org, + ) + self._create_config(device=device_no_creds) + device_deactivated = self._create_device( + name="device-deactivated", + mac_address="00:11:22:33:44:03", + organization=org, + ) + self._create_config(device=device_deactivated) + dd_cred = self._create_credentials(name="device-dd-cred", organization=org) + self._create_device_connection( + device=device_deactivated, credentials=dd_cred + ) + device_deactivated.deactivate() + batch = self._create_batch_command( + organization=org, + devices=[device_ok, device_no_creds, device_deactivated], + ) + batch.create_commands() + batch.refresh_from_db() + command_qs = Command.objects.filter(batch_command=batch) + self.assertEqual(command_qs.count(), 1) + self.assertTrue(command_qs.filter(device=device_ok).exists()) + self.assertIn(str(device_no_creds.pk), batch.skipped_devices) + self.assertIn( + "Device has no credentials assigned", + batch.skipped_devices[str(device_no_creds.pk)][0], + ) + self.assertIn(str(device_deactivated.pk), batch.skipped_devices) + self.assertIn( + "Device is deactivated", + batch.skipped_devices[str(device_deactivated.pk)][0], + ) + self.assertNotIn(str(device_ok.pk), batch.skipped_devices) + db_batch = BatchCommand.objects.get(pk=batch.pk) + self.assertEqual(batch.skipped_devices, db_batch.skipped_devices) + + def test_batch_command_resolve_devices(self): + org = self._get_org() + device1 = self._create_device( + name="device1", + mac_address="00:11:22:33:44:01", + organization=org, + ) + device2 = self._create_device( + name="device2", + mac_address="00:11:22:33:44:02", + organization=org, + ) + + with self.subTest("explicit devices via M2M"): + batch = self._create_batch_command( + organization=org, + devices=[device1], + ) + resolved = list(batch.resolve_devices()) + self.assertEqual(resolved, [device1]) + + with self.subTest("organization-scoped (no explicit devices)"): + batch = self._create_batch_command(organization=org) + resolved = list(batch.resolve_devices()) + self.assertIn(device1, resolved) + self.assertIn(device2, resolved) + + with self.subTest("group filtering"): + group = DeviceGroup.objects.create(name="test-group", organization=org) + device1.group = group + device1.save() + batch = self._create_batch_command(organization=org, group=group) + resolved = list(batch.resolve_devices()) + self.assertIn(device1, resolved) + self.assertNotIn(device2, resolved) + + with self.subTest("location filtering"): + location = Location.objects.create( + name="test-location", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device2, location=location) + batch = self._create_batch_command(organization=org, location=location) + resolved = list(batch.resolve_devices()) + self.assertIn(device2, resolved) + self.assertNotIn(device1, resolved) + + with self.subTest("group and location combined"): + DeviceLocation.objects.create(content_object=device1, location=location) + batch = self._create_batch_command( + organization=org, + group=device1.group, + location=location, + ) + resolved = list(batch.resolve_devices()) + self.assertIn(device1, resolved) + self.assertNotIn(device2, resolved) + + with self.subTest("no devices match"): + empty_group = DeviceGroup.objects.create( + name="empty-group", + organization=org, + ) + batch = self._create_batch_command( + organization=org, + group=empty_group, + ) + resolved = list(batch.resolve_devices()) + self.assertEqual(resolved, []) + + def test_batch_command_dry_run_method(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + + with self.subTest("dry_run with type"): + result = BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + ) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry_run without type"): + result = BatchCommand.dry_run(organization=org) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry_run with explicit devices"): + result = BatchCommand.dry_run( + organization=org, + devices=[device], + ) + self.assertEqual(result["devices"], [device]) + + with self.subTest("dry_run with group"): + group = DeviceGroup.objects.create(name="dry-run-group", organization=org) + device.group = group + device.save() + result = BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + group=group, + ) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry_run with location"): + location = Location.objects.create( + name="dry-run-loc", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device, location=location) + result = BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + location=location, + ) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry_run with group and location"): + result = BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + group=group, + location=location, + ) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry run org-wide"): + device2 = self._create_device( + name="dry-org-dev2", + mac_address="00:11:22:33:44:77", + organization=org, + ) + result = BatchCommand.dry_run(organization=org) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + self.assertIn(device2, result["devices"]) + + def test_batch_command_execute_method(self): + org = self._get_org() + empty_org = self._create_org(name="empty-org", slug="empty-org") + + with self.subTest("execute with no devices"): + with self.assertRaises(ValidationError) as ctx: + BatchCommand.execute( + organization=empty_org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + self.assertIn( + "No devices match", + str(ctx.exception), + ) + + cred = self._create_credentials( + name="exec-cred", + organization=org, + ) + device1 = self._create_device( + name="exec-dev1", + mac_address="00:11:22:33:44:e1", + organization=org, + ) + self._create_config(device=device1) + self._create_device_connection(device=device1, credentials=cred) + device2 = self._create_device( + name="exec-dev2", + mac_address="00:11:22:33:44:e2", + organization=org, + ) + self._create_config(device=device2) + self._create_device_connection(device=device2, credentials=cred) + group = DeviceGroup.objects.create(name="exec-group", organization=org) + device1.group = group + device1.save() + location = Location.objects.create( + name="exec-loc", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device2, location=location) + + with self.subTest("execute with explicit devices"): + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + devices=[device1], + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.batch_commands.first().device, device1) + + with self.subTest("execute with group"): + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + group=group, + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.batch_commands.first().device, device1) + + with self.subTest("execute with location"): + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + location=location, + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.batch_commands.first().device, device2) + + with self.subTest("execute with group and location"): + DeviceLocation.objects.create(content_object=device1, location=location) + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + group=group, + location=location, + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.batch_commands.first().device, device1) + + with self.subTest("execute org-wide"): + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 2) + cmd_devices = [c.device for c in batch.batch_commands.all()] + self.assertIn(device1, cmd_devices) + self.assertIn(device2, cmd_devices) + + def test_batch_command_execute_org_mismatch(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device( + name="exec-mm-dev", + mac_address="00:11:22:33:44:99", + organization=org2, + ) + self._create_config(device=device_org2) + self._create_device_connection(device=device_org2) + + with self.subTest("device org mismatch"): + with self.assertRaises(ValidationError) as ctx: + BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + devices=[device_org2], + ) + self.assertIn("devices", ctx.exception.message_dict) + self.assertIn( + "must belong to the same organization", + ctx.exception.message_dict["devices"][0], + ) + + with self.subTest("group org mismatch"): + group_org2 = DeviceGroup.objects.create( + name="exec-mm-group", + organization=org2, + ) + with self.assertRaises(ValidationError) as ctx: + BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + group=group_org2, + ) + self.assertIn("group", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Mass command " + "and the organization of the related Device Group match", + ctx.exception.message_dict["group"][0], + ) + + with self.subTest("location org mismatch"): + location_org2 = Location.objects.create( + name="exec-mm-loc", + type="indoor", + organization=org2, + ) + with self.assertRaises(ValidationError) as ctx: + BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + location=location_org2, + ) + self.assertIn("location", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Mass command " + "and the organization of the related location match", + ctx.exception.message_dict["location"][0], + ) + + def test_batch_command_dry_run_org_mismatch(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device( + name="dry-mm-dev", + mac_address="00:11:22:33:44:98", + organization=org2, + ) + + with self.subTest("device org mismatch"): + with self.assertRaises(ValidationError) as ctx: + BatchCommand.dry_run( + organization=org, + devices=[device_org2], + ) + self.assertIn("devices", ctx.exception.message_dict) + self.assertIn( + "must belong to the same organization", + ctx.exception.message_dict["devices"][0], + ) + + with self.subTest("group org mismatch"): + group_org2 = DeviceGroup.objects.create( + name="dry-mm-group", + organization=org2, + ) + with self.assertRaises(ValidationError) as ctx: + BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + group=group_org2, + ) + self.assertIn("group", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Mass command " + "and the organization of the related Device Group match", + ctx.exception.message_dict["group"][0], + ) + + with self.subTest("location org mismatch"): + location_org2 = Location.objects.create( + name="dry-mm-loc", + type="indoor", + organization=org2, + ) + with self.assertRaises(ValidationError) as ctx: + BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + location=location_org2, + ) + self.assertIn("location", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Mass command " + "and the organization of the related location match", + ctx.exception.message_dict["location"][0], + ) + + def test_batch_command_create_commands_idempotent(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + batch = self._create_batch_command( + organization=org, + devices=[device], + ) + batch.create_commands() + self.assertEqual(Command.objects.filter(batch_command=batch).count(), 1) + batch.create_commands() + self.assertEqual( + Command.objects.filter(batch_command=batch).count(), + 1, + "create_commands must be idempotent", + ) + + def test_batch_command_calculate_and_update_status(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + dc = self._create_device_connection(device=device) + batch = self._create_batch_command(organization=org) + + with self.subTest("no commands shows idle"): + batch.status = "in-progress" + batch.save(update_fields=["status"]) + batch.calculate_and_update_status() + batch.refresh_from_db() + self.assertEqual(batch.status, "idle") + + with self.subTest("all in-progress shows in-progress"): + Command.objects.create( + batch_command=batch, + device=device, + connection=dc, + type=batch.type, + input={"command": "echo test"}, + status="in-progress", + ) + batch.calculate_and_update_status() + batch.refresh_from_db() + self.assertEqual(batch.status, "in-progress") + + with self.subTest("some failed shows failed"): + Command.objects.filter(batch_command=batch).update(status="success") + device2 = self._create_device( + name="device2", + mac_address="00:11:22:33:44:02", + organization=org, + ) + self._create_config(device=device2) + dc2 = self._create_device_connection( + device=device2, + credentials=self._create_credentials( + name="Test credentials 2", + organization=org, + ), + ) + Command.objects.create( + batch_command=batch, + device=device2, + connection=dc2, + type=batch.type, + input={"command": "echo test"}, + status="failed", + ) + batch.calculate_and_update_status() + batch.refresh_from_db() + self.assertEqual(batch.status, "failed") + + with self.subTest("all success shows success"): + batch2 = self._create_batch_command(organization=org) + Command.objects.create( + batch_command=batch2, + device=device, + connection=dc, + type=batch2.type, + input={"command": "echo test"}, + status="success", + ) + batch2.calculate_and_update_status() + batch2.refresh_from_db() + self.assertEqual(batch2.status, "success") + + with self.subTest("all success with skipped shows failed"): + batch3 = self._create_batch_command(organization=org) + batch3.skipped_devices = {str(device.pk): ["no credentials"]} + batch3.save(update_fields=["skipped_devices"]) + Command.objects.create( + batch_command=batch3, + device=device, + connection=dc, + type=batch3.type, + input={"command": "echo test"}, + status="success", + ) + batch3.calculate_and_update_status() + batch3.refresh_from_db() + self.assertEqual(batch3.status, "failed") + + with self.subTest("no change shows no extra save"): + initial_modified = batch2.modified + batch2.calculate_and_update_status() + batch2.refresh_from_db() + self.assertEqual(batch2.modified, initial_modified) + + def test_batch_command_permissions(self): + ct = ContentType.objects.get_by_natural_key( + app_label=self.app_label, model="batchcommand" + ) + operator_group = Group.objects.get(name="Operator") + admin_group = Group.objects.get(name="Administrator") + operator_permissions = operator_group.permissions.filter(content_type=ct) + admin_permissions = admin_group.permissions.filter(content_type=ct) + + with self.subTest("operator permissions"): + self.assertEqual(operator_permissions.count(), 2) + self.assertTrue( + operator_permissions.filter(codename="add_batchcommand").exists() + ) + self.assertTrue( + operator_permissions.filter(codename="view_batchcommand").exists() + ) + + with self.subTest("administrator permissions"): + self.assertEqual(admin_permissions.count(), 4) + class TestModelsTransaction(BaseTestModels, TransactionTestCase): def _prepare_conf_object(self, organization=None): diff --git a/openwisp_controller/connection/tests/test_selenium.py b/openwisp_controller/connection/tests/test_selenium.py index e70c67abd..82dde1c8b 100644 --- a/openwisp_controller/connection/tests/test_selenium.py +++ b/openwisp_controller/connection/tests/test_selenium.py @@ -1,3 +1,5 @@ +from time import sleep + from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.test import tag from django.urls import reverse @@ -53,13 +55,12 @@ def test_command_widget_on_device(self): by=By.CSS_SELECTOR, value='button.ow-command-btn[data-command="reboot"]' ).click() self.find_element(by=By.CSS_SELECTOR, value="#ow-command-confirm-yes").click() - - self.assertEqual(Command.objects.count(), 1) - # TODO: Selenium tests do not support websocket connections. - # Thus, we need to refresh the page. Remove this when support for - # websockets is added. + # Wait for the redirect triggered by command submission to complete. + # Navigating away immediately can race with the redirect + sleep(0.3) self.open(path) self.wait_for_visibility( By.CSS_SELECTOR, "#tabs-container li.recent.commands", ) + self.assertEqual(Command.objects.count(), 1) diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py index 587c08209..e88109ebe 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -1,3 +1,4 @@ +import json import uuid from contextlib import redirect_stderr from io import StringIO @@ -16,6 +17,7 @@ Command = load_model("connection", "Command") DeviceConnection = load_model("connection", "DeviceConnection") OrganizationConfigSettings = load_model("config", "OrganizationConfigSettings") +BatchCommand = load_model("connection", "BatchCommand") class TestTasks(CreateConnectionsMixin, TestCase): @@ -315,3 +317,182 @@ def _delete_then_raise(self): with mock.patch.object(Command, "execute", _delete_then_raise): tasks.launch_command(command.pk) self.assertFalse(Command.objects.filter(pk=command.pk).exists()) + + @mock.patch("logging.Logger.warning") + def test_launch_batch_command_skips_deleted_batch(self, mocked_warning): + batch_id = uuid.uuid4() + tasks.launch_batch_command(batch_id=batch_id) + mocked_warning.assert_called_with( + f"The BatchCommand object with id {batch_id} has been deleted" + ) + + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") + def test_launch_batch_command_creates_commands(self, mocked_delay): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo 'test'"}, + label="test-label", + ) + batch.full_clean() + batch.save() + batch.devices.set([device]) + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 1) + command = batch.batch_commands.first() + self.assertEqual(command.device, device) + self.assertEqual(command.type, batch.type) + self.assertEqual(command.status, "in-progress") + mocked_delay.assert_called_once_with(command.pk) + + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") + def test_launch_batch_command_creates_commands_for_multiple_devices( + self, mocked_delay + ): + org = self._get_org() + cred = self._create_credentials(organization=org, name="Multi device cred") + devices = [] + for i in range(2): + d = self._create_device( + name=f"task-dev-{i}", + mac_address=f"00:11:22:33:44:{i + 0x50:02x}", + organization=org, + ) + self._create_config(device=d) + self._create_device_connection(device=d, credentials=cred) + devices.append(d) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + batch.devices.set(devices) + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 2) + cmd_devices = [c.device for c in batch.batch_commands.all()] + for d in devices: + self.assertIn(d, cmd_devices) + self.assertEqual(batch.status, "in-progress") + self.assertEqual(mocked_delay.call_count, 2) + + @mock.patch( + "openwisp_controller.connection.base.models.AbstractBatchCommand" + ".create_commands", + side_effect=SoftTimeLimitExceeded(), + ) + def test_launch_batch_command_timeout(self, mocked_create_commands): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.status, "failed") + + @mock.patch( + "openwisp_controller.connection.base.models.AbstractBatchCommand" + ".create_commands", + side_effect=RuntimeError("test error"), + ) + def test_launch_batch_command_exception(self, mocked_create_commands): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + + with self.subTest("custom command"): + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + with redirect_stderr(StringIO()) as stderr: + tasks.launch_batch_command(batch_id=batch.pk) + self.assertIn( + f"An exception was raised while executing batch command {batch.pk}", + stderr.getvalue(), + ) + batch.refresh_from_db() + self.assertEqual(batch.status, "failed") + + with self.subTest("change_password input is cleaned up"): + password = "SuperSecret123" + batch = BatchCommand( + organization=org, + type="change_password", + input={"password": password, "confirm_password": password}, + label="test-pwd", + ) + batch.full_clean() + batch.save() + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.status, "failed") + self.assertNotIn(password, json.dumps(batch.input)) + + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") + def test_launch_batch_command_all_devices_skipped(self, mocked_delay): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + device.deactivate() + device.config.set_status_deactivated() + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + batch.devices.set([device]) + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 0) + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertEqual(batch.status, "failed") + mocked_delay.assert_not_called() + + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") + def test_launch_batch_command_already_processed(self, mocked_delay): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + batch.devices.set([device]) + # First call — creates commands + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 1) + first_call_count = mocked_delay.call_count + # Second call — should be a no-op (idempotency guard) + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(mocked_delay.call_count, first_call_count) diff --git a/openwisp_controller/connection/tests/utils.py b/openwisp_controller/connection/tests/utils.py index e8b477791..1574e2364 100644 --- a/openwisp_controller/connection/tests/utils.py +++ b/openwisp_controller/connection/tests/utils.py @@ -11,6 +11,7 @@ Credentials = load_model("connection", "Credentials") DeviceConnection = load_model("connection", "DeviceConnection") Command = load_model("connection", "Command") +BatchCommand = load_model("connection", "BatchCommand") class SshServer(Server): @@ -118,6 +119,24 @@ def _create_device_connection(self, **kwargs): dc.save() return dc + def _create_batch_command(self, organization, **kwargs): + opts = dict( + organization=organization, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + devices = kwargs.pop("devices", None) + opts.update(kwargs) + batch = BatchCommand(**opts) + batch.full_clean() + batch.save() + if devices is not None: + if not isinstance(devices, (list, tuple)): + devices = [devices] + batch.devices.set(devices) + return batch + class CreateCommandMixin(CreateConnectionsMixin): def _create_command(self, device_conn=None, device_conn_opts={}, **kwargs): diff --git a/openwisp_controller/geo/tests/test_api.py b/openwisp_controller/geo/tests/test_api.py index 156550692..df46460cf 100644 --- a/openwisp_controller/geo/tests/test_api.py +++ b/openwisp_controller/geo/tests/test_api.py @@ -694,7 +694,7 @@ def test_change_location_type_to_outdoor_api(self): def test_delete_location_detail(self): l1 = self._create_location() path = reverse("geo_api:detail_location", args=[l1.pk]) - with self.assertNumQueries(5): + with self.assertNumQueries(6): response = self.client.delete(path) self.assertEqual(response.status_code, 204) diff --git a/tests/openwisp2/sample_connection/api/views.py b/tests/openwisp2/sample_connection/api/views.py index fd2207cbc..22d04a924 100644 --- a/tests/openwisp2/sample_connection/api/views.py +++ b/tests/openwisp2/sample_connection/api/views.py @@ -1,3 +1,12 @@ +from openwisp_controller.connection.api.views import ( + BatchCommandDetailView as BaseBatchCommandDetailView, +) +from openwisp_controller.connection.api.views import ( + BatchCommandExecuteView as BaseBatchCommandExecuteView, +) +from openwisp_controller.connection.api.views import ( + BatchCommandListView as BaseBatchCommandListView, +) from openwisp_controller.connection.api.views import ( CommandDetailsView as BaseCommandDetailsView, ) @@ -42,9 +51,24 @@ class DeviceConnectionDetailView(BaseDeviceConnectionDetailView): pass +class BatchCommandExecuteView(BaseBatchCommandExecuteView): + pass + + +class BatchCommandListView(BaseBatchCommandListView): + pass + + +class BatchCommandDetailView(BaseBatchCommandDetailView): + pass + + command_list_create_view = CommandListCreateView.as_view() command_details_view = CommandDetailsView.as_view() credential_list_create_view = CredentialListCreateView.as_view() credential_detail_view = CredentialDetailView.as_view() deviceconnection_list_create_view = DeviceConnectionListCreateView.as_view() deviceconnection_detail_view = DeviceConnectionDetailView.as_view() +batch_command_execute_view = BatchCommandExecuteView.as_view() +batch_command_list_view = BatchCommandListView.as_view() +batch_command_detail_view = BatchCommandDetailView.as_view() diff --git a/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py b/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py new file mode 100644 index 000000000..c0f67afd1 --- /dev/null +++ b/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py @@ -0,0 +1,176 @@ +# Generated by Django 5.2.15 on 2026-06-15 18:15 + +import uuid + +import django +import django.core.serializers.json +import django.db.migrations.operations.special +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +from django.db import migrations, models + +from openwisp_controller import connection as connection_config +from openwisp_controller.connection.migrations import ( + assign_batchcommand_permissions_to_groups, +) + + +class Migration(migrations.Migration): + + dependencies = [ + ("sample_config", "0009_replace_jsonfield_with_django_builtin"), + ("sample_connection", "0004_replace_jsonfield_with_django_builtin"), + ("sample_geo", "0005_organizationgeosettings"), + ("sample_users", "0005_user_expiration_date_user_user_active_expiry_idx"), + ] + + operations = [ + migrations.CreateModel( + name="BatchCommand", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("idle", "idle"), + ("in-progress", "in progress"), + ("success", "success"), + ("failed", "failed"), + ], + default="idle", + max_length=12, + ), + ), + ( + "type", + models.CharField( + max_length=16, + choices=( + connection_config.commands.COMMAND_CHOICES + if django.VERSION < (5, 0) + else connection_config.commands.get_command_choices + ), + ), + ), + ( + "input", + models.JSONField( + blank=True, + encoder=django.core.serializers.json.DjangoJSONEncoder, + null=True, + ), + ), + ( + "devices", + models.ManyToManyField( + blank=True, to="sample_config.device", verbose_name="devices" + ), + ), + ( + "skipped_devices", + models.JSONField( + blank=True, + null=True, + default=dict, + verbose_name="Skipped devices", + help_text=( + "Maps device UUIDs to validation error messages for " + "devices that were skipped during command creation." + ), + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="sample_config.devicegroup", + verbose_name="device group", + ), + ), + ( + "location", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="sample_geo.location", + verbose_name="location", + ), + ), + ( + "label", + models.CharField( + max_length=64, + verbose_name="label", + help_text="A short label to identify this batch command.", + ), + ), + ( + "notes", + models.TextField( + blank=True, + default="", + verbose_name="notes", + help_text="Optional notes about this batch command.", + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="sample_users.organization", + ), + ), + ], + options={ + "verbose_name": "Mass command", + "verbose_name_plural": "Mass commands", + "abstract": False, + }, + ), + migrations.AddField( + model_name="command", + name="batch_command", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="batch_commands", + to="sample_connection.batchcommand", + ), + ), + migrations.RunPython( + code=assign_batchcommand_permissions_to_groups, + reverse_code=django.db.migrations.operations.special.RunPython.noop, + ), + ] diff --git a/tests/openwisp2/sample_connection/models.py b/tests/openwisp2/sample_connection/models.py index 7964c5471..4e96065e8 100644 --- a/tests/openwisp2/sample_connection/models.py +++ b/tests/openwisp2/sample_connection/models.py @@ -1,6 +1,7 @@ from django.db import models from openwisp_controller.connection.base.models import ( + AbstractBatchCommand, AbstractCommand, AbstractCredentials, AbstractDeviceConnection, @@ -27,3 +28,8 @@ class Meta(AbstractDeviceConnection.Meta): class Command(AbstractCommand): class Meta(AbstractCommand.Meta): abstract = False + + +class BatchCommand(AbstractBatchCommand): + class Meta(AbstractBatchCommand.Meta): + abstract = False diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index 94b1b14ef..a6efa105c 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -304,6 +304,7 @@ CONNECTION_CREDENTIALS_MODEL = "sample_connection.Credentials" CONNECTION_DEVICECONNECTION_MODEL = "sample_connection.DeviceConnection" CONNECTION_COMMAND_MODEL = "sample_connection.Command" + CONNECTION_BATCHCOMMAND_MODEL = "sample_connection.BatchCommand" SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL = ( "sample_subnet_division.SubnetDivisionRule" ) From b5cd5baeb30c440de2fd9a4c4b10f957dd2a4692 Mon Sep 17 00:00:00 2001 From: Deepanshu Sahu Date: Thu, 17 Sep 2026 20:48:00 +0530 Subject: [PATCH 3/9] [feature] Django admin workflow for mass commands with real-time monitoring #1345 Added a Django admin workflow for launching mass commands by organization, device group, or location. Before execution, users can review the selected devices and exclude individual devices if needed. Added result pages to monitor mass command execution, with filtering, search, skipped-device details, and real-time updates for command status, output, and overall progress. Closes #1345 --- docs/user/intro.rst | 6 +- docs/user/rest-api.rst | 6 +- docs/user/shell-commands.rst | 94 +- docs/user/websocket-api.rst | 133 ++ .../config/base/channels_consumer.py | 19 +- openwisp_controller/connection/admin.py | 923 ++++++++++- openwisp_controller/connection/apps.py | 48 +- openwisp_controller/connection/base/models.py | 421 +++-- .../connection/channels/consumers.py | 147 ++ .../connection/channels/routing.py | 6 +- openwisp_controller/connection/filters.py | 40 + openwisp_controller/connection/handlers.py | 74 + ...0011_batchcommand_command_batch_command.py | 14 +- .../static/connection/css/batch-command.css | 312 ++++ .../static/connection/css/command-inline.css | 3 + .../static/connection/js/batch-command.js | 395 +++++ .../static/connection/js/execute-command.js | 265 +++ openwisp_controller/connection/tasks.py | 3 + .../batch_command_change_form.html | 186 +++ .../batch_command/confirm_command.html | 133 ++ .../batch_command/execute_command.html | 82 + .../connection/batch_command/form_row.html | 22 + .../connection/tests/pytest.py | 415 +++++ .../connection/tests/test_admin.py | 1136 ++++++++++++- .../connection/tests/test_api.py | 182 +- .../connection/tests/test_models.py | 480 +++++- .../connection/tests/test_selenium.py | 1467 ++++++++++++++++- .../connection/tests/test_tasks.py | 60 +- openwisp_controller/connection/tests/utils.py | 35 + openwisp_controller/connection/utils.py | 9 + openwisp_controller/connection/widgets.py | 35 + .../geo/estimated_location/tests/tests.py | 5 +- ...0005_batchcommand_command_batch_command.py | 14 +- tests/openwisp2/sample_connection/pytest.py | 8 + tests/openwisp2/sample_connection/tests.py | 24 + 35 files changed, 6927 insertions(+), 275 deletions(-) create mode 100644 openwisp_controller/connection/filters.py create mode 100644 openwisp_controller/connection/handlers.py 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/static/connection/js/execute-command.js create mode 100644 openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html create mode 100644 openwisp_controller/connection/templates/admin/connection/batch_command/confirm_command.html create mode 100644 openwisp_controller/connection/templates/admin/connection/batch_command/execute_command.html create mode 100644 openwisp_controller/connection/templates/admin/connection/batch_command/form_row.html diff --git a/docs/user/intro.rst b/docs/user/intro.rst index a9c9092d4..24b5095df 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -70,9 +70,9 @@ e.g.: - Sending configuration updates. - :doc:`Executing shell commands `. -- :doc:`Executing mass commands `: Run a command on - multiple devices at once, see the :ref:`batch command API - ` for details. +- :ref:`Executing mass commands `: Run a command on + multiple devices at once, from the admin or with the :ref:`batch command + API `. - Perform firmware upgrades via the additional :doc:`firmware upgrade module `. diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index f007e3ba3..d81a24bb3 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -506,7 +506,8 @@ Parameter Description ``?type=custom&input=%7B%22command%22%3A%22uptime%22%7D`` ``devices`` Repeated ``devices`` query parameter, each a device UUID (optional; when provided, ``group`` and ``location`` are - ignored) + ignored; all devices must belong to the same + organization) ``group`` Device group UUID (optional) ``location`` Location UUID (optional) ================ ========================================================= @@ -534,7 +535,8 @@ Parameter Description (**required**) ``notes`` Optional notes (optional) ``devices`` List of device UUIDs (optional; when provided, ``group`` - and ``location`` are ignored) + and ``location`` are ignored; all devices must belong to + the same organization) ``group`` Device group UUID (optional) ``location`` Location UUID (optional) ================ ========================================================= diff --git a/docs/user/shell-commands.rst b/docs/user/shell-commands.rst index d80c59806..810c8ae71 100644 --- a/docs/user/shell-commands.rst +++ b/docs/user/shell-commands.rst @@ -195,24 +195,86 @@ useful for rebooting all devices in a group, changing passwords across multiple devices, or running diagnostics on all devices in an organization. -**Targeting options:** +Sending a Mass Command +~~~~~~~~~~~~~~~~~~~~~~ -- ``organization``: All devices in an organization. -- ``devices``: Explicit list of device UUIDs. -- ``group``: Device group UUID. -- ``location``: Location UUID. +Open *Network Operations* > *Run Mass command* from the menu. The first +step asks for: -If ``devices`` is provided, ``group`` and ``location`` are ignored. -Otherwise, ``group`` and ``location`` can be used together to narrow the -target set within the organization. +- the **command type** and its inputs, which change with the type + selected; +- a **label** to identify the mass command later, and optional **notes**; +- the **targets**: organization, device group and location. -If no targeting options are provided, the command targets all devices in -the organization. Superusers can omit ``organization`` to target all -devices across organizations. +The targets decide which devices are matched. Using more than one narrows +the selection: a group and a location together match only the devices +which are in that group *and* at that location. -For superusers, ``organization`` is set automatically when ``group`` or -``location`` is provided. +Superusers can leave every target empty to run the command on all the +devices of the system. Other users must choose at least one target, and +only see the command types enabled for their organizations (see +:ref:`openwisp_controller_organization_enabled_commands`). -Refer to the :ref:`Batch Command API ` -documentation for the available endpoints, request parameters, and -examples. +Reviewing the Devices +~~~~~~~~~~~~~~~~~~~~~ + +The second step shows a summary of the command and the list of the devices +it matched. + +Devices can be left out by unchecking them: the counter and the *Execute +on N devices* button follow the selection, which is kept while paging +through the list. *Back* returns to the first step with the form still +filled in. + +The mass command starts when the *Execute* button is clicked. + +Following the Results +~~~~~~~~~~~~~~~~~~~~~ + +After executing, the mass command page opens. It shows the status of the +mass command, how many devices are affected, the devices which were +skipped, and one row per device with its status and output. + +The rows are updated in real time, so the page does not need to be +reloaded to follow the progress. The table can be searched by device name +and filtered by status, device group and location (and by organization for +superusers). + +.. note:: + + Commands are executed asynchronously in the background: each device is + handled by an independent background task, so how many devices are + contacted at the same time depends on the concurrency of the Celery + workers. A mass command sent to many devices keeps updating for a + while after the page is opened. + +Finding Past Mass Commands +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +*Network Operations* > *Mass commands* lists the mass commands which were +sent, most recent first. + +The list can be searched by label, notes, organization, device, location +and group name, and filtered by organization, status, type, group and +location. Clicking a mass command opens the page described above. + +Skipped Devices +~~~~~~~~~~~~~~~ + +A device is skipped when the command cannot be created for it, for example +when the device has no access credentials, or when the command type is not +enabled for its organization. + +Skipped devices are not executed, but they are shown: the **Skipped +devices** field summarizes how many there are and why, and each one is +listed in the results table with the *skipped* status and the reason as +its output. They can be found with the status filter. + +Using the API +~~~~~~~~~~~~~ + +The same operations are available over the REST API, which also accepts an +explicit list of devices instead of the targets described above. All +explicit devices must belong to the same organization. Refer to the +:ref:`Batch Command API ` documentation for +the available endpoints, request parameters and examples. diff --git a/docs/user/websocket-api.rst b/docs/user/websocket-api.rst index 781b3d59a..b53a78037 100644 --- a/docs/user/websocket-api.rst +++ b/docs/user/websocket-api.rst @@ -17,6 +17,8 @@ All endpoints: shown in JavaScript-style notation with inline comments for readability. - Push real-time updates after the connection is established. - Do not accept client messages: any data sent from the client is ignored. + The only exception is the mass command endpoint, which accepts the + single request documented below. Authentication and Authorization -------------------------------- @@ -161,3 +163,134 @@ After the connection is established, the server pushes a message every time the geometry of any mobile location in a subscribed organization is updated. The payload is identical to the one documented for the `2. Single Location Updates`_ endpoint. + +4. Mass Command Updates +~~~~~~~~~~~~~~~~~~~~~~~ + +Connection URL: + +:: + + wss:///ws/controller/batch-command/ + +Scope ++++++ + +Progress of a single mass command: its status and the result of every +device it runs on. See :ref:`mass_commands`. + +Authorization ++++++++++++++ + +A user is authorized if: + +- The user is a superuser, OR +- The user is marked as staff AND has the ``connection.view_batchcommand`` + or ``connection.change_batchcommand`` permission AND manages the + organization of the mass command. + +Real-time Updates ++++++++++++++++++ + +The server pushes a message every time the mass command or one of its +commands changes. The ``type`` field tells the two apart. + +When the mass command itself changes, for example when it moves from +``idle`` to ``in-progress``: + +.. code-block:: javascript + + { + "type": "batch_status", + "id": "", // Mass command identifier + "label": "", // Label given when it was sent + "notes": "", // Notes given when it was sent + "input": { /* ... */ }, // Command input, masked for "change_password" + "organization": "", // Organization, null when system wide + "group": "", // Device group target, null when not used + "location": "", // Location target, null when not used + "status": "", // "idle", "in-progress", "success" or "failed" + "created": "", // ISO 8601 timestamp + "modified": "", // ISO 8601 timestamp + "affected_devices": , // Number of devices the command runs on + "skipped_count": , // Number of devices which were skipped + "skipped_preview": [ /* ... */], // First and last skipped devices, with the reason + "total_rows": // Affected plus skipped devices + } + +The status and the timestamps are sent as they are stored, without +translation or formatting, so that each client can render them with its +own language and time zone. Command rows carry ``modified_display`` as +well, which is the same timestamp already formatted with the locale and +the time zone of the server: the admin uses it so that a row updated over +the websocket reads exactly like the rows rendered with the page. + +When the command of one device changes: + +.. code-block:: javascript + + { + "type": "command_update", + "id": "", // Command identifier + "device": "", // Device identifier + "device_name": "", // Device name + "connection": "", // Device connection used, may be null + "batch_command": "", // Mass command this command belongs to + "status": "", // "in-progress", "success" or "failed" + "output": "", // Output preview: the last line of the + // output, at most its last 100 + // characters, as in "batch_state" + "created": "", // ISO 8601 timestamp + "modified": "", // ISO 8601 timestamp + "modified_display": "", // Modified, formatted by the server with its + // own locale and time zone + "index": , // Position of the row, sent only for new commands + "affected_devices": , // Commands created so far, sent with "index" + "total_rows": // Affected plus skipped devices, sent with "index" + } + +Requesting the Current State +++++++++++++++++++++++++++++ + +A client which connects while the mass command is already running can ask +for the results it missed: + +.. code-block:: javascript + + { + "type": "request_current_state", + "page": 1, // Page of results, 20 rows per page + "filters": { // Optional, the filters the page is showing + "q": "", // Search term matched against the device name + "status": "", // Command status, or "skipped" + "location_id": "", // Location of the device + "group_id": "", // Device group + "organization_id": "" // Organization of the device + } + } + +Every filter is optional and an empty string means the filter is not +active. The filters are applied before the results are paginated, so a +client which is showing a filtered table receives the same rows it would +get by reloading the page. + +The server replies with one message holding that page: + +.. code-block:: javascript + + { + "type": "batch_state", + "batch_status": { /* ... */ }, // Batch serializer fields, including + // the command type, but without + // "skipped_devices" or "total_rows" + "commands": [ /* ... */ ], // Rows of the requested page. Command + // rows include the command type and omit + // event-only fields such as "index". + // Their "output" is a 100-character tail. + "page": , // Effective page after bounds checking + "total_rows": // Rows matching the filters, for the paginator + } + +Rows of devices which were skipped are included in ``commands`` with +``is_skipped`` set to ``true``, a ``status`` of ``skipped`` and the reason +in ``output``. diff --git a/openwisp_controller/config/base/channels_consumer.py b/openwisp_controller/config/base/channels_consumer.py index 870ae7340..134949d2d 100644 --- a/openwisp_controller/config/base/channels_consumer.py +++ b/openwisp_controller/config/base/channels_consumer.py @@ -10,7 +10,8 @@ class BaseDeviceConsumer(WebsocketConsumer): channel_layer_group = "config.device" def _is_user_authenticated(self): - return self.scope["user"].is_authenticated is True + user = self.scope.get("user") + return user is not None and user.is_authenticated def is_user_authorized(self): user = self.scope["user"] @@ -32,16 +33,18 @@ def _user_has_permissions(self, add=True, change=True, delete=True): return self.scope["user"].has_perms(permissions) def connect(self): + if not self._is_user_authenticated() or not self.is_user_authorized(): + self.close() + return try: - assert self._is_user_authenticated() and self.is_user_authorized() self.pk_ = self.scope["url_route"]["kwargs"]["pk"] - except (AssertionError, KeyError): + except KeyError: self.close() - else: - async_to_sync(self.channel_layer.group_add)( - f"{self.channel_layer_group}-{self.pk_}", self.channel_name - ) - self.accept() + return + async_to_sync(self.channel_layer.group_add)( + f"{self.channel_layer_group}-{self.pk_}", self.channel_name + ) + self.accept() def disconnect(self, close_code): try: diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py index f15c98d58..2e299dce6 100644 --- a/openwisp_controller/connection/admin.py +++ b/openwisp_controller/connection/admin.py @@ -1,26 +1,52 @@ +import hashlib +import logging from datetime import timedelta +from types import SimpleNamespace +from uuid import UUID, uuid4 import reversion import swapper from django import forms -from django.contrib import admin -from django.http import HttpResponseForbidden, JsonResponse +from django.contrib import admin, messages +from django.core.cache import cache +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator +from django.db.models import Count +from django.http import HttpResponseForbidden, HttpResponseNotAllowed, JsonResponse +from django.shortcuts import redirect +from django.template.response import TemplateResponse 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 .commands import ORGANIZATION_COMMAND_SCHEMA +from .filters import GroupFilter, LocationFilter, TypeFilter from .schema import schema -from .widgets import CommandSchemaWidget, CredentialsSchemaWidget +from .utils import format_localized_datetime +from .widgets import ( + BatchCommandSchemaWidget, + CommandSchemaWidget, + CredentialsSchemaWidget, + OrganizationScopedSelect, +) + +logger = logging.getLogger(__name__) Credentials = swapper.load_model("connection", "Credentials") DeviceConnection = swapper.load_model("connection", "DeviceConnection") Command = swapper.load_model("connection", "Command") +BatchCommand = swapper.load_model("connection", "BatchCommand") +Device = swapper.load_model("config", "Device") +DeviceGroup = swapper.load_model("config", "DeviceGroup") +Location = swapper.load_model("geo", "Location") +Organization = swapper.load_model("openwisp_users", "Organization") class CredentialsForm(forms.ModelForm): @@ -35,6 +61,135 @@ class Meta: widgets = {"input": CommandSchemaWidget} +class BatchCommandExecutionForm(forms.ModelForm): + required_css_class = "required" + + class Meta: + model = BatchCommand + fields = [ + "organization", + "label", + "notes", + "type", + "input", + "group", + "location", + ] + widgets = { + "label": forms.TextInput(attrs={"class": "vTextField ow-text-field"}), + "notes": forms.Textarea(attrs={"rows": 3}), + "input": BatchCommandSchemaWidget, + "group": OrganizationScopedSelect, + "location": OrganizationScopedSelect, + } + + class Media: + js = [ + "admin/js/vendor/jquery/jquery.min.js", + "admin/js/vendor/select2/select2.full.min.js", + "admin/js/jquery.init.js", + "connection/js/execute-command.js", + ] + css = { + "screen": [ + "admin/css/vendor/select2/select2.min.css", + "admin/css/autocomplete.css", + ] + } + + def __init__(self, *args, request=None, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + if request is None or request.user.is_superuser: + # without a request there is no user to restrict the fields to, + # while superusers are allowed to use every organization, group, + # location and command type, so both keep the fields as they are + return + organization_ids = request.user.organizations_managed + self.fields["organization"].queryset = self.fields[ + "organization" + ].queryset.filter(id__in=organization_ids) + for field_name in ("group", "location"): + self.fields[field_name].queryset = self.fields[field_name].queryset.filter( + organization_id__in=organization_ids + ) + allowed_commands = {} + for organization_id in organization_ids: + allowed_commands.update( + dict(Command.get_org_allowed_commands(organization_id=organization_id)) + ) + # unlike the fields above, "type" is not a queryset but a static + # list of choices, so replacing it drops the "Select an option" + # placeholder: without it a command type select button is preselected with + # first available command type + empty_choices = [ + choice for choice in self.fields["type"].choices if not choice[0] + ] + self.fields["type"].choices = empty_choices + list(allowed_commands.items()) + + def clean(self): + cleaned_data = super().clean() + if self.request is None or self.request.user.is_superuser: + return cleaned_data + organization = cleaned_data.get("organization") + group = cleaned_data.get("group") + location = cleaned_data.get("location") + # a batch without any target would run on every device of the + # deployment, which only superusers are allowed to do + if not any([organization, group, location]): + raise ValidationError( + _( + "Please select at least one of: organization, device group," + " or location." + ) + ) + return cleaned_data + + def fieldsets(self): + """Returns the sections of the first step with their bound fields. + + The page is not an admin change form, so Django does not group the + fields: the sections are listed here, in the order they are shown, + and the template renders each field with the admin markup. + """ + sections = ( + (_("Command"), ("type", "input", "label", "notes")), + (_("Targets"), ("organization", "location", "group")), + ) + return [ + (title, [self[field_name] for field_name in field_names]) + for title, field_names in sections + ] + + def to_session(self): + """Returns the cleaned values as JSON serializable primitives. + + The session uses the JSON serializer, so model instances and UUIDs + cannot be stored as they are. + """ + + def _pk(value): + return str(value.pk) if value else None + + return { + # Namespaces the device selection the confirm page keeps in + # sessionStorage, which lives as long as the browser tab: without + # it a wizard would inherit the devices unselected by a previous + # one. It has to be issued here rather than by the browser, + # because the session is shared between tabs and sessionStorage + # is not. It is also submitted back on execution, which runs + # only the wizard the confirm page was rendered with. + "token": uuid4().hex, + "type": self.cleaned_data["type"], + "label": self.cleaned_data["label"], + "notes": self.cleaned_data.get("notes") or "", + "input": self.cleaned_data.get("input"), + "organization_id": _pk(self.cleaned_data.get("organization")), + "group_id": _pk(self.cleaned_data.get("group")), + "location_id": _pk(self.cleaned_data.get("location")), + } + + @admin.register(Credentials) class CredentialsAdmin(MultitenantAdminMixin, TimeReadonlyAdminMixin, admin.ModelAdmin): list_display = ( @@ -108,6 +263,7 @@ class CommandInline(admin.StackedInline): "type", "input_data", "output_data", + "batch_command", "created", "modified", ] @@ -116,6 +272,7 @@ class CommandInline(admin.StackedInline): "type", "input_data", "output_data", + "batch_command", "created", "modified", ] @@ -134,7 +291,7 @@ def get_queryset(self, request, select_related=True): device_id=resolved.kwargs["object_id"], created__gte=seven_days ).order_by("-created") if select_related: - qs = qs.select_related() + qs = qs.select_related().prefetch_related("batch_command") return qs def input_data(self, obj): @@ -215,3 +372,757 @@ def schema_view(self, request): CommandInline, ] DeviceAdmin.add_reversion_following(follow=["deviceconnection_set"]) + + +class BatchCommandDeviceAdminMixin: + """Applied on top of the ModelAdmin registered for Device, for + openwisp-monitoring which replaces that registration and + its extra columns must appear. + + Filters and search are emptied because the devices are already chosen on + the execute page, this table only excludes some of them. + """ + + list_display_links = ["name"] + list_filter = [] + search_fields = [] + actions = None + list_per_page = 20 + change_list_template = "admin/connection/batch_command/confirm_command.html" + import_export_change_list_template = None + + def __init__(self, model, admin_site, devices=None): + super().__init__(model, admin_site) + self.devices = devices + + def get_list_display(self, request): + return ["select_device"] + list(super().get_list_display(request)) + + def get_queryset(self, request): + return super().get_queryset(request).filter(pk__in=self.devices) + + @admin.display(description="") + def select_device(self, obj): + # the checkbox has no "name" because it is never submitted: it sits + # in the changelist form, not the execute form, and only exists for + # the current page, so the JS stores the unchecked devices in the + # browser's sessionStorage to keep them across pages, and submits + # them in the hidden "excluded" field instead + return format_html( + '', + obj.pk, + _("Include {}").format(obj.name), + ) + + +class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): + execute_command_template = "admin/connection/batch_command/execute_command.html" + confirm_command_template = "admin/connection/batch_command/confirm_command.html" + change_form_template = ( + "admin/connection/batch_command/batch_command_change_form.html" + ) + session_key = "batch_command_wizard" + wizard_claim_timeout = 60 + 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", + ] + device_commands_per_page = 20 + exclude = ("devices",) + fields = [ + "organization_display", + "label", + "notes", + "colored_status", + "type", + "formatted_input", + "affected_devices", + "display_skipped_devices", + "group", + "location", + "created", + "modified", + ] + readonly_fields = [ + "organization_display", + "colored_status", + "formatted_input", + "affected_devices", + "display_skipped_devices", + ] + + class Media: + css = { + "all": [ + "admin/css/changelists.css", + "admin/css/ow-filters.css", + "connection/css/batch-command.css", + ] + } + + def get_urls(self): + options = self.model._meta + return [ + path( + "execute/", + self.admin_site.admin_view(self.execute_command_view), + name=f"{options.app_label}_{options.model_name}_execute", + ), + path( + "confirm/", + self.admin_site.admin_view(self.confirm_command_view), + name=f"{options.app_label}_{options.model_name}_confirm", + ), + path( + "ui/schema.json", + self.admin_site.admin_view(self.schema_view), + name=f"{options.app_label}_{options.model_name}_schema", + ), + ] + super().get_urls() + + def _check_add_permission(self, request): + permission = f"{self.opts.app_label}.add_{self.opts.model_name}" + if not request.user.has_perm(permission): + raise PermissionDenied + + def schema_view(self, request): + """Returns the schemas of the command types the wizard offers. + + The type choices are the union over the organizations the user + manages, so the schemas are too: the editor fetches them once on + page load, before an organization has been picked. + """ + self._check_add_permission(request) + schemas = {} + if request.user.is_superuser: + for organization_schemas in ORGANIZATION_COMMAND_SCHEMA.values(): + schemas.update(organization_schemas) + else: + for organization_id in request.user.organizations_managed: + schemas.update( + Command.get_org_schema(organization_id=organization_id) or {} + ) + return JsonResponse(schemas) + + @staticmethod + def _wizard_initial(wizard): + return { + "type": wizard.get("type"), + "input": wizard.get("input"), + "label": wizard.get("label"), + "notes": wizard.get("notes"), + "organization": wizard.get("organization_id"), + "group": wizard.get("group_id"), + "location": wizard.get("location_id"), + } + + def execute_command_view(self, request): + """First step of the mass command workflow: collect the details. + A valid submission goes to the session and redirects to the confirm + page, so that its device table can be paginated with plain GETs. + """ + if request.method not in ("GET", "POST"): + return HttpResponseNotAllowed(["GET", "POST"]) + self._check_add_permission(request) + if request.method == "POST": + form = BatchCommandExecutionForm(request.POST, request=request) + if form.is_valid(): + # the POST on the confirm page requires the data of this step, + # hidden inputs cannot carry it because changing the page of + # the paginated device table refreshes the page and loses it, + # so it is stored in the session + request.session[self.session_key] = form.to_session() + return redirect( + f"admin:{self.opts.app_label}_{self.opts.model_name}_confirm" + ) + else: + wizard = request.session.get(self.session_key) + if request.GET.get("back") and wizard: + form = BatchCommandExecutionForm( + initial=self._wizard_initial(wizard), request=request + ) + else: + # the wizard is left in the session: another tab may be + # reviewing it, and opening this page must not discard it + form = BatchCommandExecutionForm(request=request) + context = { + **self.admin_site.each_context(request), + "title": _("Execute mass command"), + "opts": self.opts, + "form": form, + "media": form.media, + "has_view_permission": self.has_view_permission(request), + } + return TemplateResponse(request, self.execute_command_template, context) + + def confirm_command_view(self, request): + """Second step: review the targeted devices and dispatch the command. + Dispatching is decided by the HTTP method alone. + """ + if request.method not in ("GET", "POST"): + return HttpResponseNotAllowed(["GET", "POST"]) + self._check_add_permission(request) + if request.method == "POST": + return self._execute_batch_command(request) + wizard = request.session.get(self.session_key) + if not wizard: + return self._restart(request) + devices = self._resolve_target_queryset(request, wizard) + digest = self._devices_digest(devices.values_list("pk", flat=True)) + if wizard.get("devices_digest") not in (None, digest): + wizard["token"] = uuid4().hex + wizard["devices_digest"] = digest + request.session[self.session_key] = wizard + device_admin = self.get_device_admin(devices) + # changelist_view() assembles the whole changelist context (cl, + # media, pagination) and renders the change_list_template of the + # mixin, which is the confirm page extending the stock changelist + # template + return device_admin.changelist_view( + request, extra_context=self._confirm_context(request, wizard, devices) + ) + + def get_device_admin(self, devices): + """Builds the ModelAdmin rendering the device table of the confirm page. + Composed with whichever ModelAdmin is registered for Device, so the + table shows the columns of the device changelist as it actually is: + openwisp-monitoring replaces that registration to add its own. + Resolved per request, when the registration is final. + """ + registered = self.admin_site.get_model_admin(Device).__class__ + # the mixin comes first so that its attributes win over the + # registered admin's + device_admin_class = type( + "BatchCommandDeviceAdmin", + (BatchCommandDeviceAdminMixin, registered), + {"readonly_fields": list(registered.readonly_fields)}, + ) + return device_admin_class(Device, self.admin_site, devices=devices) + + def get_device_changelist_template(self): + """The template the registered Device admin renders its changelist with. + The confirm page extends it rather than the stock one, because that + is where other modules load the assets their columns need. Read from + the class: django-import-export rewrites it on the instance. + """ + registered = self.admin_site.get_model_admin(Device).__class__ + return getattr(registered, "change_list_template", None) or ( + "admin/change_list.html" + ) + + def _restart(self, request, message=None): + """Sends the user back to step one when there is no wizard to show.""" + self.message_user( + request, + message or _("Please fill in the mass command details to continue."), + messages.WARNING, + ) + return redirect(f"admin:{self.opts.app_label}_{self.opts.model_name}_execute") + + def _resolve_target_queryset(self, request, wizard): + """Devices picked one by one, or matched by the organization, group + and location chosen. The targeting rule lives on the model so this + page and the execution cannot drift apart; the multitenancy scope is + an admin concern, applied on top. + """ + try: + devices = BatchCommand.dry_run( + organization_id=wizard.get("organization_id"), + group_id=wizard.get("group_id"), + location_id=wizard.get("location_id"), + )["devices"] + except (ObjectDoesNotExist, ValidationError) as error: + logger.warning( + "Failed to resolve devices for mass command wizard" + " (organization_id=%s, group_id=%s, location_id=%s): %s", + wizard.get("organization_id"), + wizard.get("group_id"), + wizard.get("location_id"), + error, + ) + return Device.objects.none() + if not request.user.is_superuser: + devices = devices.filter( + organization_id__in=request.user.organizations_managed + ) + return devices.distinct() + + def _claim_wizard(self, wizard): + """Claims a wizard for execution, returning False when it was claimed. + + Two overlapping requests read the wizard from the session before + either of them saves its removal, so deleting it from the session + does not stop the second one: adding a key to the cache does, because + only one request can add it. + """ + return cache.add( + f"{self.session_key}-claim:{wizard['token']}", + True, + timeout=self.wizard_claim_timeout, + ) + + def _release_wizard(self, wizard): + cache.delete(f"{self.session_key}-claim:{wizard['token']}") + + def _devices_digest(self, device_ids): + """Identifies the set of devices a confirm page was rendered with, + so that only the reviewed set is executed. + """ + pks = sorted(str(pk) for pk in device_ids) + return hashlib.sha256(",".join(pks).encode()).hexdigest() + + def _confirm_context(self, request, wizard, devices): + targets = [] + for model, key in ( + (Organization, "organization_id"), + (DeviceGroup, "group_id"), + (Location, "location_id"), + ): + if not wizard.get(key): + continue + target = model.objects.filter(pk=wizard[key]).first() + if target: + targets.append(str(target)) + command_types = dict(BatchCommand._meta.get_field("type").choices) + return { + "title": _("Review mass command"), + "batch_opts": self.opts, + # the template this page extends, see get_device_changelist_template() + "device_changelist_template": self.get_device_changelist_template(), + "wizard": wizard, + "command_type_display": command_types.get(wizard["type"], wizard["type"]), + "command_description": self._describe_input(wizard.get("input")), + "targets_display": ", ".join(targets) if targets else _("All devices"), + "device_count": devices.count(), + "has_view_permission": self.has_view_permission(request), + } + + def _describe_input(self, command_input): + """Renders the submitted input for the review step, so that every + registered command type is shown and not only custom ones. + """ + if not isinstance(command_input, dict): + return "" + if "command" in command_input: + return command_input["command"] + return ", ".join( + f"{key}: {value}" + for key, value in command_input.items() + if "password" not in key.lower() + ) + + def _execute_batch_command(self, request): + """Runs the mass command shown on the confirm page. + + The wizard is deleted from the session before the command is + created, so it can only run once: + + - no wizard in the session, because it expired or already ran: + go back to the first step + - the token does not match, because another tab replaced it: + go back to the first step and say why + - the devices changed since the page was opened: keep the wizard + and ask the user to review them again + - the organization, group or location was deleted: log it and go + back to the first step + - the command is invalid: keep the wizard and show the error + - otherwise: run it on the reviewed devices, minus the unchecked + ones, and open the new mass command + """ + wizard = request.session.get(self.session_key) + if not wizard: + return self._restart(request) + if request.POST.get("token") != wizard.get("token"): + return self._restart( + request, + _( + "This mass command was replaced by another one started in a" + " different browser tab, so it was not executed. Please fill" + " in the details again." + ), + ) + if not self._claim_wizard(wizard): + return self._restart(request) + del request.session[self.session_key] + devices = self._resolve_target_queryset(request, wizard) + device_ids = list(devices.values_list("pk", flat=True)) + if self._devices_digest(device_ids) != wizard.get("devices_digest"): + self._release_wizard(wizard) + request.session[self.session_key] = wizard + self.message_user( + request, + _("The targeted devices changed, please review them again."), + messages.WARNING, + ) + return redirect( + f"admin:{self.opts.app_label}_{self.opts.model_name}_confirm" + ) + # The confirm page only lists the devices matched on the execute + # page, so the selection can only ever remove from that set: the + # browser never supplies a device to add. + excluded = self._get_pk_list(request.POST, "excluded") + selection = Device.objects.filter(pk__in=device_ids).exclude(pk__in=excluded) + kwargs = { + "type": wizard["type"], + "label": wizard["label"], + "input": wizard.get("input"), + "notes": wizard.get("notes") or "", + "organization_id": wizard.get("organization_id"), + "group_id": wizard.get("group_id"), + "location_id": wizard.get("location_id"), + "devices": list(selection), + "system_wide": request.user.is_superuser + and not any( + ( + wizard.get("organization_id"), + wizard.get("group_id"), + wizard.get("location_id"), + ) + ), + } + try: + batch = BatchCommand.execute(**kwargs) + except ObjectDoesNotExist as error: + self._release_wizard(wizard) + logger.warning( + "Failed to execute mass command wizard" + " (organization_id=%s, group_id=%s, location_id=%s): %s", + wizard.get("organization_id"), + wizard.get("group_id"), + wizard.get("location_id"), + error, + ) + return self._restart(request) + except ValidationError as error: + # put the wizard back so the user can correct the selection + self._release_wizard(wizard) + request.session[self.session_key] = wizard + self.message_user(request, error.messages[0], messages.ERROR) + return redirect( + f"admin:{self.opts.app_label}_{self.opts.model_name}_confirm" + ) + self.message_user( + request, _("Mass command executed successfully."), messages.SUCCESS + ) + return redirect( + f"admin:{self.opts.app_label}_{self.opts.model_name}_change", batch.pk + ) + + @staticmethod + def _get_uuid(value): + try: + return str(UUID(str(value))) + except (AttributeError, TypeError, ValueError): + return "" + + @staticmethod + def _get_pk_list(source, name): + pks = ( + BatchCommandAdmin._get_uuid(pk) for pk in source.get(name, "").split(",") + ) + return [pk for pk in pks if pk] + + def get_readonly_fields(self, request, obj=None): + fields = super().get_readonly_fields(request, obj) + return fields + list(self.__class__.readonly_fields) + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .annotate(_affected_devices=Count("batch_commands", distinct=True)) + ) + + def get_object(self, request, object_id, from_field=None): + """Avoids duplicating queries in change_view custom logic""" + cache_attr = f"_cached_object_{object_id}_{from_field}" + if not hasattr(request, cache_attr): + setattr( + request, cache_attr, super().get_object(request, object_id, from_field) + ) + return getattr(request, cache_attr) + + def _get_commands(self, request, obj): + qs = Command.objects.filter(batch_command=obj).select_related("device") + return BatchCommand.scope_commands(qs, request.user) + + def organization_display(self, obj): + if obj.organization: + return obj.organization.name + # Will return Shared systemwide (no organization) after + # https://github.com/openwisp/openwisp-users/issues/238 + 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 "-" + if obj.type == "change_password": + return "********" + return self._describe_input(obj.input) or "-" + + formatted_input.short_description = _("input") + + def affected_devices(self, obj): + count = getattr(obj, "_affected_devices", None) + if count is None: + count = obj.affected_devices + return count + + affected_devices.short_description = _("affected devices") + affected_devices.admin_order_field = "_affected_devices" + + def display_skipped_devices(self, obj): + if not obj.skipped_count: + return "-" + rows = obj.get_skipped_preview() + lines = [str(obj.skipped_count)] + lines += [ + format_html("{}: {}", row["device_name"], row["output"]) for row in rows + ] + if len(rows) < obj.skipped_count: + lines.insert(-1, "\u2026") + 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, + ): + """Builds the filters of the command table. + + The table is not a changelist, so Django does not build its filters: + each one is a title and a list of choices, and every choice carries + the query string which applies it. Filters which cannot narrow the + table are left out. + """ + filter_specs = [] + params = request.GET.copy() + # the filtered table is shorter, so the page being viewed may not + # exist in it: every choice starts again from the first page + params.pop("page", None) + + 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}" + return { + "display": display, + "selected": current_value == value, + "query_string": query_string, + } + + # the statuses a command can have, plus "skipped" for the devices + # no command was created for and "All" to clear the filter + 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) + ) + filter_specs.append(SimpleNamespace(title=_("status"), choices=status_choices)) + # the locations and groups offered are those of the organization of + # the mass command, and only those the user is allowed to see + locations = Location.objects.all() + groups = DeviceGroup.objects.all() + if obj.organization_id: + locations = locations.filter(organization_id=obj.organization_id) + groups = groups.filter(organization_id=obj.organization_id) + if not request.user.is_superuser: + locations = locations.filter( + organization_id__in=request.user.organizations_managed + ) + groups = groups.filter( + organization_id__in=request.user.organizations_managed + ) + # a mass command sent to a single location: every row already has + # that location, so the filter is not created + location_spec = None + if not obj.location_id: + location_spec = self._build_related_filter( + _("location"), + "location_id", + current_location or "", + locations.values_list("id", "name"), + _make_choice, + ) + if location_spec: + filter_specs.append(location_spec) + # the same when the mass command was sent to a single device group + group_spec = None + if not obj.group_id: + group_spec = self._build_related_filter( + _("device group"), + "group_id", + current_group or "", + groups.values_list("id", "name"), + _make_choice, + ) + if group_spec: + filter_specs.append(group_spec) + # devices of different organizations only meet in a mass command + # which is system wide, and those are triggered by superusers only + if request.user.is_superuser and not obj.organization_id: + org_spec = self._build_related_filter( + _("organization"), + "organization_id", + current_org or "", + Organization.objects.values_list("id", "name"), + _make_choice, + ) + if org_spec: + filter_specs.append(org_spec) + return filter_specs + + def _build_related_filter(self, title, param_name, current_value, qs, make_choice): + choices = [make_choice(current_value, _("All"), param_name, "")] + for obj_id, obj_name in qs: + if obj_id: + choices.append( + make_choice(current_value, obj_name, param_name, str(obj_id)) + ) + if len(choices) <= 1: + return None + return SimpleNamespace(title=title, choices=choices) + + @staticmethod + def _command_row(command): + return { + "device_name": command.device.name, + "device": command.device.pk, + "status": command.status, + "status_display": command.get_status_display(), + "output": command.output_preview, + "modified_display": format_localized_datetime(command.modified), + "is_skipped": False, + } + + def _paginate_commands( + self, batch, commands_qs, skipped_items, page_param, per_page=None + ): + """Returns one page of rows without loading the whole batch in memory. + Commands keep the ordering of ``AbstractCommand.Meta`` ("created"), + so the newest is always last and the change page can append results + live. Skipped devices are not Command rows, they come as a list and + follow the commands. + """ + per_page = per_page or self.device_commands_per_page + commands_count = commands_qs.count() + total = commands_count + len(skipped_items) + paginator = Paginator(range(total), per_page) + try: + page_obj = paginator.page(page_param or 1) + except (PageNotAnInteger, EmptyPage): + page_obj = paginator.page(1) + start = (page_obj.number - 1) * per_page + end = start + per_page + commands_end = min(end, commands_count) + rows = [ + self._command_row(command) for command in commands_qs[start:commands_end] + ] + skipped_start = max(0, start - commands_count) + skipped_end = max(0, end - commands_count) + rows += batch.get_skipped_rows(skipped_start, skipped_end, items=skipped_items) + return page_obj, paginator, rows + + def _get_active_filters(self, request): + return { + "q": request.GET.get("q", ""), + "status": request.GET.get("status", ""), + "location_id": self._get_uuid(request.GET.get("location_id", "")), + "group_id": self._get_uuid(request.GET.get("group_id", "")), + "organization_id": self._get_uuid(request.GET.get("organization_id", "")), + } + + 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: + commands_qs = self._get_commands(request, obj) + filters = self._get_active_filters(request) + commands_qs = obj.filter_commands(commands_qs, filters) + skipped_items = [] + if obj.skipped_count and filters["status"] in ("", "skipped"): + skipped_items = obj.filter_skipped_items(filters) + page_obj, paginator, commands = self._paginate_commands( + obj, commands_qs, skipped_items, request.GET.get("page", 1) + ) + filter_specs = self._build_filter_specs( + request, + obj, + filters["status"], + current_location=filters["location_id"], + current_group=filters["group_id"], + current_org=filters["organization_id"], + ) + extra_context.update( + { + "commands": commands, + "page_obj": page_obj, + "paginator": paginator, + "filter_specs": filter_specs, + "has_active_filters": any( + value for key, value in filters.items() if key != "q" + ), + "device_opts": Device._meta, + "status_labels": { + status: str(label) + for status, label in ( + *BatchCommand.STATUS_CHOICES, + *Command.STATUS_CHOICES, + ("skipped", _("skipped")), + ) + }, + } + ) + return super().change_view(request, object_id, extra_context=extra_context) + + +admin.site.register(BatchCommand, BatchCommandAdmin) diff --git a/openwisp_controller/connection/apps.py b/openwisp_controller/connection/apps.py index f67e326d0..874c991bc 100644 --- a/openwisp_controller/connection/apps.py +++ b/openwisp_controller/connection/apps.py @@ -1,5 +1,3 @@ -from asgiref.sync import async_to_sync -from channels import layers from django.apps import AppConfig from django.db import transaction from django.db.models.signals import post_save @@ -8,7 +6,7 @@ from openwisp_notifications.types import register_notification_type from swapper import get_model_name, load_model -from openwisp_utils.admin_theme.menu import register_menu_subitem +from openwisp_utils.admin_theme.menu import register_menu_group, register_menu_subitem from ..config.signals import config_deactivating, config_modified from .signals import is_working_changed @@ -34,9 +32,10 @@ def ready(self): self.notification_cache_update() self.register_menu_groups() + from . import handlers # noqa + Config = load_model("config", "Config") Credentials = load_model("connection", "Credentials") - Command = load_model("connection", "Command") config_modified.connect( self.config_modified_receiver, dispatch_uid="connection.update_config" @@ -56,30 +55,10 @@ def ready(self): dispatch_uid="is_working_changed_receiver", ) - post_save.connect( - self.command_save_receiver, - sender=Command, - dispatch_uid="command_save_handler", - ) - @classmethod def config_modified_receiver(cls, **kwargs): transaction.on_commit(lambda: cls._launch_update_config(kwargs["device"])) - @classmethod - def command_save_receiver(cls, sender, created, instance, **kwargs): - from .api.serializers import CommandSerializer - - channel_layer = layers.get_channel_layer() - if created: - # Trigger websocket message only when command status is updated - return - serialized_data = CommandSerializer(instance).data - async_to_sync(channel_layer.group_send)( - f"config.device-{instance.device_id}", - {"type": "send.update", "model": "Command", "data": serialized_data}, - ) - @classmethod def _launch_update_config(cls, device): """ @@ -188,3 +167,24 @@ def register_menu_groups(self): "icon": "ow-access-credential", }, ) + register_menu_group( + position=35, + config={ + "label": _("Network Operations"), + "icon": "ow-network-operations", + "items": { + 1: { + "label": _("Run Mass command"), + "model": get_model_name("connection", "BatchCommand"), + "name": "execute", + "icon": "ow-run-mass-command", + }, + 2: { + "label": _("Mass commands"), + "model": get_model_name("connection", "BatchCommand"), + "name": "changelist", + "icon": "ow-mass-commands", + }, + }, + }, + ) diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index dee7b5105..53b3bf6be 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -1,10 +1,13 @@ import logging +from itertools import islice +from uuid import UUID import jsonschema from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.db import DatabaseError, models, transaction from django.db.models import JSONField +from django.db.models.signals import post_save from django.utils import timezone from django.utils.functional import cached_property from django.utils.module_loading import import_string @@ -484,7 +487,7 @@ def get_org_allowed_commands(self, organization_id=None): Returns a list of allowed commands for the given organization """ allowed_commands = ORGANIZATION_ENABLED_COMMANDS.get( - str(organization_id), ORGANIZATION_ENABLED_COMMANDS.get("__all__") + str(organization_id), ORGANIZATION_ENABLED_COMMANDS.get("__all__", ()) ) commands_map = dict(COMMAND_CHOICES) return [ @@ -534,14 +537,25 @@ def _verify_command_type_allowed(self): raise ValidationError( { "input": _( - ( - '"{command}" command is not available ' - "for this organization" - ).format(command=self.type) - ) + '"{command}" command is not available for this organization' + ).format(command=self.type) } ) + @property + def output_preview(self): + """Last line of the output, for tables which list many commands.""" + lines = (self.output or "").strip().splitlines() + if not lines: + return "" + max_length = 100 + line = lines[-1] + truncated = len(lines) > 1 + if len(line) > max_length: + line = line[-max_length:] + truncated = True + return f"… {line}" if truncated else line + @property def is_custom(self): return self.type == "custom" @@ -599,6 +613,32 @@ def execute(self): if self.device.is_fully_deactivated(): self.status = "failed" self._add_output("Device is deactivated.") + elif ( + self.batch_command_id + and self.batch_command.organization_id + and self.batch_command.organization_id != self.device.organization_id + ): + self.status = "failed" + self._add_output( + gettext( + 'The device was moved out of "%(organization)s", the' + ' organization of the mass command "%(label)s", so the' + " command was not run." + ) + % { + "organization": self.batch_command.organization, + "label": self.batch_command.label, + } + ) + logger.warning( + "Not executing command %s of batch %s: device %s belongs to" + " organization %s instead of %s", + self.pk, + self.batch_command_id, + self.device_id, + self.device.organization_id, + self.batch_command.organization_id, + ) else: exit_code = self._exec_command() # if output is None, the commands couldn't execute @@ -747,7 +787,7 @@ class AbstractBatchCommand(ValidateOrgMixin, TimeStampedEditableModel): ) type = models.CharField( max_length=16, - choices=(COMMAND_CHOICES if django.VERSION < (5, 0) else get_command_choices), + choices=get_command_choices, ) input = JSONField(blank=True, null=True, encoder=DjangoJSONEncoder) group = models.ForeignKey( @@ -784,10 +824,10 @@ class AbstractBatchCommand(ValidateOrgMixin, TimeStampedEditableModel): blank=True, null=True, default=dict, - verbose_name=_("Skipped devices"), + verbose_name=_("skipped devices"), help_text=_( - "Maps device UUIDs to validation error messages for devices " - "that were skipped during command creation." + "Maps device UUIDs to the name of the device and the validation " + "error that caused it to be skipped during command creation." ), ) @@ -799,8 +839,162 @@ class Meta: def __str__(self): return self.label - @cached_property + @property def total_devices(self): + return self.affected_devices + self.skipped_count + + @property + def skipped_count(self): + return len(self.skipped_devices or {}) + + @property + def skipped_device_ids(self): + return (self.skipped_devices or {}).keys() + + @staticmethod + def build_skipped_row(device_pk, skipped): + return { + "device": str(device_pk), + "device_name": skipped["name"], + "status": "skipped", + "status_display": gettext("skipped"), + "output": skipped["error"], + "modified": None, + "modified_display": "", + "is_skipped": True, + } + + @staticmethod + def normalize_filters(filters): + if not isinstance(filters, dict): + filters = {} + filters = { + key: str(filters.get(key) or "") + for key in ("q", "status", "location_id", "group_id", "organization_id") + } + # Sanitize UUID filters to ignore malformed client data. + for key in ("location_id", "group_id", "organization_id"): + if not filters[key]: + continue + try: + filters[key] = str(UUID(filters[key])) + except ValueError: + filters[key] = "" + return filters + + def filter_skipped_items(self, filters): + """Applies the filters of the command table to the skipped devices. + + Unlike the other rows of the table, a skipped device has no Command + object, so it has no status which the database can filter: it only + exists in the "skipped_devices" JSON of the mass command. The table + filters, including the "skipped" status choice, therefore look up the + skipped devices in that JSON instead of querying the Command model. + """ + related = ( + filters["organization_id"], + filters["group_id"], + filters["location_id"], + ) + device_ids = None + if any(related): + organization_id, group_id, location_id = related + Device = load_model("config", "Device") + devices = Device.objects.filter(pk__in=self.skipped_device_ids) + if organization_id: + devices = devices.filter(organization_id=organization_id) + if group_id: + devices = devices.filter(group_id=group_id) + if location_id: + devices = devices.filter(devicelocation__location_id=location_id) + device_ids = {str(pk) for pk in devices.values_list("pk", flat=True)} + return self.get_skipped_items(query=filters["q"], device_ids=device_ids) + + @staticmethod + def scope_commands(queryset, user): + """Limits the commands to the devices the user is allowed to see. + + A device can be moved to another organization after its command was + created, so belonging to the mass command is not enough: the rows of + such a device are hidden from everyone but a superuser. + """ + if user.is_superuser: + return queryset + return queryset.filter(device__organization_id__in=user.organizations_managed) + + def filter_commands(self, queryset, filters): + status = filters["status"] + if status == "skipped": + return queryset.none() + if filters["q"]: + queryset = queryset.filter(device__name__icontains=filters["q"]) + if status: + queryset = queryset.filter(status=status) + if filters["location_id"]: + queryset = queryset.filter( + device__devicelocation__location_id=filters["location_id"] + ) + if filters["group_id"]: + queryset = queryset.filter(device__group_id=filters["group_id"]) + if filters["organization_id"]: + queryset = queryset.filter( + device__organization_id=filters["organization_id"] + ) + return queryset + + def get_skipped_items(self, query="", device_ids=None): + skipped_devices = self.skipped_devices or {} + if not query and device_ids is None: + return skipped_devices.items() + query = query.lower() + return [ + (pk, skipped) + for pk, skipped in skipped_devices.items() + if (device_ids is None or pk in device_ids) + and (not query or query in skipped["name"].lower()) + ] + + def get_skipped_rows(self, start=0, end=None, items=None): + if items is None: + items = (self.skipped_devices or {}).items() + return [ + self.build_skipped_row(pk, skipped) + for pk, skipped in islice(items, start, end) + ] + + def get_skipped_preview(self, limit=10): + skipped = self.skipped_devices or {} + if len(skipped) <= limit: + return self.get_skipped_rows() + last_pk = next(reversed(skipped)) + return self.get_skipped_rows(end=2) + [ + self.build_skipped_row(last_pk, skipped[last_pk]) + ] + + @staticmethod + def get_command_row_position(command): + """Returns where a new command appears in the table of its mass + command, and the totals the table shows once it is added. + + Commands are listed in the order they were created, followed by + the skipped devices, so the position of a new command is the number + of commands created before it. The loop which creates the commands + passes that number in "_batch_index", so they are not counted again + for every command; otherwise they are counted here. + """ + batch = command.batch_command + index = getattr(command, "_batch_index", None) + if index is None: + index = batch.affected_devices - 1 + affected_devices = index + 1 + return { + "index": index, + "affected_devices": affected_devices, + "total_rows": affected_devices + batch.skipped_count, + } + + @property + def affected_devices(self): return self.batch_commands.count() @property @@ -815,18 +1009,17 @@ def failed(self): def _validate_device_org(device, organization_id): if organization_id and device.organization_id != organization_id: raise ValidationError( - { - "devices": _( - "All devices must belong to the same " - "organization as the batch command." - ) - } + {"devices": _("All devices must belong to the same organization.")} ) @classmethod - def _validate_devices_org(cls, devices, organization_id): + def _validate_devices_org(cls, devices, organization_id, system_wide=False): if not devices: return + if not organization_id: + if system_wide: + return + organization_id = devices[0].organization_id for device in devices: cls._validate_device_org(device, organization_id) @@ -835,16 +1028,11 @@ def _validate_org_relations(self): return self._validate_org_relation("group", field_error="group") self._validate_org_relation("location", field_error="location") - if self.pk and self.devices.exists(): + if not self._state.adding and self.devices.exists(): org_mismatch = self.devices.exclude(organization=self.organization).exists() if org_mismatch: raise ValidationError( - { - "devices": _( - "All devices must belong to the same " - "organization as the batch command." - ) - } + {"devices": _("All devices must belong to the same organization.")} ) def clean(self): @@ -863,7 +1051,8 @@ def clean(self): raise ValidationError( { "type": _( - '"{command}" command is not available for this organization' + '"{command}" command is not available for the target' + " organization(s)" ).format(command=self.type) } ) @@ -876,12 +1065,16 @@ def clean(self): def resolve_devices(self): """ - Returns an iterator of devices targeted by this batch command, + Returns a queryset of devices targeted by this batch command, resolved from explicit M2M devices or filtered by organization, - group, and location. Returns an empty iterator if no devices match. + group, and location. Callers which walk the whole result should + consume it with iterator(). """ if self.pk and self.devices.exists(): - return self.devices.select_related("config").iterator() + qs = self.devices.select_related("config") + if self.organization_id: + qs = qs.filter(organization_id=self.organization_id) + return qs Device = load_model("config", "Device") qs = Device.objects.select_related("config") if self.organization_id: @@ -890,7 +1083,7 @@ def resolve_devices(self): qs = qs.filter(group=self.group) if self.location: qs = qs.filter(devicelocation__location=self.location) - return qs.iterator() + return qs @classmethod def execute(cls, **kwargs): @@ -900,16 +1093,21 @@ def execute(cls, **kwargs): devices match the criteria. """ devices_list = kwargs.pop("devices", None) + system_wide = kwargs.pop("system_wide", False) batch = cls(**kwargs) with transaction.atomic(): batch.full_clean() batch.save() - if devices_list is not None: + if devices_list is None: + devices_list = list(batch.resolve_devices()) batch.devices.set(devices_list) - batch._validate_org_relations() else: - batch.devices.set(list(batch.resolve_devices())) - if not batch.devices.exists(): + cls._validate_devices_org( + devices_list, batch.organization_id, system_wide=system_wide + ) + batch.devices.set(devices_list) + batch._validate_org_relations() + if not devices_list: raise ValidationError( _("No devices match the specified criteria."), ) @@ -938,12 +1136,33 @@ def dry_run(cls, **kwargs): cls._validate_devices_org(devices_list, batch.organization_id) if devices_list is not None: return {"devices": list(devices_list)} - return {"devices": list(batch.resolve_devices())} + return {"devices": batch.resolve_devices()} def _clean_sensitive_info(self): if self.type == "change_password": self.input = {"password": "********"} + def _skip_transferred_devices(self): + if not self.pk or not self.organization_id: + return + transferred = self.devices.exclude(organization_id=self.organization_id) + for device in transferred.iterator(): + self.skipped_devices[str(device.pk)] = { + "name": device.name, + "error": gettext( + 'The device was moved out of "%(organization)s", the' + ' organization of the mass command "%(label)s", so the' + " command was not run." + ) + % {"organization": self.organization, "label": self.label}, + } + logger.warning( + "Skipping device %s for batch %s: transferred to another" + " organization", + device.pk, + self.pk, + ) + def create_commands(self): """ Creates individual Command instances for each device targeted by @@ -956,12 +1175,15 @@ def create_commands(self): ) if not updated: return - self.refresh_from_db(fields=["status"]) + self.status = "in-progress" + self.save(update_fields=["status"]) Command = load_model("connection", "Command") Device = load_model("config", "Device") self.skipped_devices = {} device_pks = [] - for device in self.resolve_devices(): + created_count = 0 + self._skip_transferred_devices() + for device in self.resolve_devices().iterator(): device_pks.append(device.pk) command = Command( device=device, @@ -970,15 +1192,16 @@ def create_commands(self): batch_command=self, ) try: - # Validate before the atomic block so errors like - # ValidationError don't create/rollback command.full_clean() - with transaction.atomic(): - command.save() + command._batch_index = created_count + command.save() + created_count += 1 except ValidationError as e: - self.skipped_devices[str(device.pk)] = ( - e.messages if hasattr(e, "messages") else [str(e)] - ) + messages = e.messages if hasattr(e, "messages") else [str(e)] + self.skipped_devices[str(device.pk)] = { + "name": device.name, + "error": ", ".join(message.rstrip(".") for message in messages), + } logger.warning( "Skipping device %s for batch %s: %s", device.pk, @@ -994,6 +1217,48 @@ def create_commands(self): if self.type == "change_password": self.save(update_fields=["input"]) + def _compute_status(self): + stats = self.batch_commands.aggregate( + total_operations=models.Count("id"), + in_progress=models.Count( + models.Case( + models.When(status="in-progress", then=1), + output_field=models.IntegerField(), + ) + ), + completed=models.Count( + models.Case( + models.When(~models.Q(status="in-progress"), then=1), + output_field=models.IntegerField(), + ) + ), + successful=models.Count( + models.Case( + models.When(status="success", then=1), + output_field=models.IntegerField(), + ) + ), + failed=models.Count( + models.Case( + models.When(status="failed", then=1), + output_field=models.IntegerField(), + ) + ), + ) + if stats["total_operations"] == 0: + if self.skipped_devices: + return "failed" + return "idle" + if stats["in_progress"] > 0: + return "in-progress" + if stats["failed"] > 0: + return "failed" + if stats["successful"] > 0 and stats["completed"] == stats["total_operations"]: + if self.skipped_devices: + return "failed" + return "success" + return self.status + def calculate_and_update_status(self): """ Calculate batch status based on individual command statuses and update if @@ -1004,52 +1269,22 @@ def calculate_and_update_status(self): - All commands completed successfully: status set to "success". - Status unchanged: no database write performed. """ - with transaction.atomic(): - batch = self.__class__.objects.select_for_update().get(pk=self.pk) - stats = batch.batch_commands.aggregate( - total_operations=models.Count("id"), - in_progress=models.Count( - models.Case( - models.When(status="in-progress", then=1), - output_field=models.IntegerField(), - ) - ), - completed=models.Count( - models.Case( - models.When(~models.Q(status="in-progress"), then=1), - output_field=models.IntegerField(), - ) - ), - successful=models.Count( - models.Case( - models.When(status="success", then=1), - output_field=models.IntegerField(), - ) - ), - failed=models.Count( - models.Case( - models.When(status="failed", then=1), - output_field=models.IntegerField(), - ) - ), - ) - if stats["total_operations"] == 0: - if batch.skipped_devices: - new_status = "failed" - else: - new_status = "idle" - elif stats["in_progress"] > 0: - new_status = "in-progress" - elif stats["failed"] > 0: - new_status = "failed" - elif ( - stats["successful"] > 0 - and stats["completed"] == stats["total_operations"] - ): - if batch.skipped_devices: - new_status = "failed" - else: - new_status = "success" - if batch.status != new_status: - batch.status = new_status - batch.save(update_fields=["status"]) + batch = self.__class__.objects.get(pk=self.pk) + new_status = batch._compute_status() + if batch.status == new_status: + return + modified = timezone.now() + if not self.__class__.objects.filter(pk=self.pk, status=batch.status).update( + status=new_status, modified=modified + ): + return + batch.status = new_status + batch.modified = modified + post_save.send( + sender=self.__class__, + instance=batch, + created=False, + raw=False, + using=self._state.db, + update_fields=frozenset({"status"}), + ) diff --git a/openwisp_controller/connection/channels/consumers.py b/openwisp_controller/connection/channels/consumers.py index 7b4955c47..e241a0de2 100644 --- a/openwisp_controller/connection/channels/consumers.py +++ b/openwisp_controller/connection/channels/consumers.py @@ -1,11 +1,18 @@ import json +import logging from copy import deepcopy +from django.contrib.auth import get_user_model from swapper import load_model from ...config.base.channels_consumer import BaseDeviceConsumer +from ..api.serializers import BatchCommandSerializer, CommandSerializer +from ..utils import format_localized_datetime + +logger = logging.getLogger(__name__) Device = load_model("config", "Device") +BatchCommand = load_model("connection", "BatchCommand") class CommandConsumer(BaseDeviceConsumer): @@ -13,3 +20,143 @@ def send_update(self, event): data = deepcopy(event) data.pop("type") self.send(json.dumps(data)) + + +class BatchCommandConsumer(BaseDeviceConsumer): + model = BatchCommand + channel_layer_group = "config.batchcommand" + per_page = 20 + current_state_message = "request_current_state" + + def _has_access(self): + """The connected user is reloaded because permissions and organization + memberships cached on the instance stored in the scope do not reflect + the changes made after the socket was accepted. + """ + user = get_user_model().objects.filter(pk=self.scope["user"].pk).first() + if user is None or not user.is_active: + return False + self.scope["user"] = user + return self.is_user_authorized() + + def send_update(self, event): + if not self._has_access(): + self.close() + return + data = dict(event["data"]) + # the device of a command can be moved to another organization after + # the command was created, so a row is forwarded only to the users + # which are allowed to see its device + organization_id = data.pop("device_organization", None) + user = self.scope["user"] + if ( + organization_id + and not user.is_superuser + and not user.is_manager(organization_id) + ): + return + self.send(json.dumps(data)) + + def is_user_authorized(self): + user = self.scope["user"] + if user.is_superuser: + return True + opts = self.model._meta + if not user.is_staff or not any( + user.has_perm(f"{opts.app_label}.{action}_{opts.model_name}") + for action in ("view", "change") + ): + return False + organization_id = ( + self.model.objects.filter(pk=self.scope["url_route"]["kwargs"]["pk"]) + .values_list("organization_id", flat=True) + .first() + ) + return bool(organization_id) and user.is_manager(str(organization_id)) + + def receive(self, text_data=None, bytes_data=None): + if text_data is None: + logger.warning("Received a binary websocket message, ignoring it") + return + try: + content = json.loads(text_data) + except ValueError: + content = None + if not isinstance(content, dict): + logger.warning( + "Received a websocket message which is not a valid JSON object" + ) + return + if not self._has_access(): + self.close() + return + message_type = content.get("type") + if message_type == self.current_state_message: + self._handle_current_state_request( + content.get("page"), content.get("filters") + ) + else: + logger.warning(f"Unknown websocket message type received: {message_type}") + + def _handle_current_state_request(self, page=None, filters=None): + """Handle request for current state of the operation. + + The page is built from the same filters the browser is showing, so a + reconnect on a filtered page reconciles against a comparable snapshot + instead of the whole batch. + """ + batch = BatchCommand.objects.filter( + pk=self.scope["url_route"]["kwargs"]["pk"] + ).first() + if not batch: + # deleted after the connection was accepted + return + filters = BatchCommand.normalize_filters(filters) + batch_status = BatchCommandSerializer(batch).data + batch_status["affected_devices"] = batch.affected_devices + batch_status.pop("skipped_devices", None) + batch_status["skipped_count"] = batch.skipped_count + batch_status["skipped_preview"] = batch.get_skipped_preview() + commands_qs = batch.filter_commands( + BatchCommand.scope_commands( + batch.batch_commands.select_related("device"), self.scope["user"] + ), + filters, + ) + commands_count = commands_qs.count() + skipped_items = [] + if batch.skipped_count and filters["status"] in ("", "skipped"): + skipped_items = batch.filter_skipped_items(filters) + try: + page = max(int(page), 1) + except (TypeError, ValueError): + page = 1 + total_rows = commands_count + len(skipped_items) + page = min(page, max((total_rows - 1) // self.per_page + 1, 1)) + start = (page - 1) * self.per_page + end = start + self.per_page + commands = [] + commands_end = min(end, commands_count) + for command in commands_qs[start:commands_end]: + row = CommandSerializer(command).data + row.pop("input", None) + row["device_name"] = command.device.name + row["output"] = command.output_preview + row["modified_display"] = format_localized_datetime(command.modified) + commands.append(row) + commands += batch.get_skipped_rows( + max(0, start - commands_count), + max(0, end - commands_count), + items=skipped_items, + ) + self.send( + json.dumps( + { + "type": "batch_state", + "batch_status": batch_status, + "commands": commands, + "page": page, + "total_rows": total_rows, + } + ) + ) diff --git a/openwisp_controller/connection/channels/routing.py b/openwisp_controller/connection/channels/routing.py index 2012b86b2..7b8afad04 100644 --- a/openwisp_controller/connection/channels/routing.py +++ b/openwisp_controller/connection/channels/routing.py @@ -8,5 +8,9 @@ def get_routes(consumer=ow_consumer): path( "ws/controller/device//command", consumer.CommandConsumer.as_asgi(), - ) + ), + path( + "ws/controller/batch-command/", + consumer.BatchCommandConsumer.as_asgi(), + ), ] diff --git a/openwisp_controller/connection/filters.py b/openwisp_controller/connection/filters.py new file mode 100644 index 000000000..b5c66355a --- /dev/null +++ b/openwisp_controller/connection/filters.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from swapper import load_model + +from openwisp_users.multitenancy import MultitenantRelatedOrgFilter + +from .commands import get_command_choices + + +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): + if request.user.is_superuser: + return list(get_command_choices()) + Command = load_model("connection", "Command") + allowed = {} + for organization_id in request.user.organizations_managed: + allowed.update( + Command.get_org_allowed_commands(organization_id=organization_id) + ) + return list(allowed.items()) + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(type=self.value()) + return queryset diff --git a/openwisp_controller/connection/handlers.py b/openwisp_controller/connection/handlers.py new file mode 100644 index 000000000..13c1a4938 --- /dev/null +++ b/openwisp_controller/connection/handlers.py @@ -0,0 +1,74 @@ +import logging + +from asgiref.sync import async_to_sync +from channels import layers +from django.db import transaction +from django.db.models.signals import post_save +from django.dispatch import receiver +from swapper import load_model + +from .utils import format_localized_datetime + +logger = logging.getLogger(__name__) + +Command = load_model("connection", "Command") +BatchCommand = load_model("connection", "BatchCommand") + + +def send_websocket_event(group, event): + def send(): + try: + async_to_sync(layers.get_channel_layer().group_send)(group, event) + except Exception: + logger.exception("Failed to send update to %s", group) + + transaction.on_commit(send) + + +def send_batch_command_websocket_event(group, data): + send_websocket_event(group, {"type": "send.update", "data": data}) + + +@receiver(post_save, sender=Command, dispatch_uid="command_save_handler") +def command_save_handler(sender, created, instance, **kwargs): + from .api.serializers import CommandSerializer + + if created and not instance.batch_command_id: + return + serialized_data = CommandSerializer(instance).data + if not created: + send_websocket_event( + f"config.device-{instance.device_id}", + {"type": "send.update", "model": "Command", "data": serialized_data}, + ) + if instance.batch_command_id: + batch_data = dict(serialized_data) + batch_data.pop("input", None) + batch_data["device_name"] = instance.device.name + batch_data["device_organization"] = str(instance.device.organization_id) + batch_data["output"] = instance.output_preview + batch_data["modified_display"] = format_localized_datetime(instance.modified) + batch_data["type"] = "command_update" + if created: + # a new command: the page adds its row live, so it needs the + # position of the row and the new totals + batch_data.update(BatchCommand.get_command_row_position(instance)) + send_batch_command_websocket_event( + f"config.batchcommand-{instance.batch_command_id}", batch_data + ) + + +@receiver(post_save, sender=BatchCommand, dispatch_uid="batch_command_save_handler") +def batch_command_save_handler(sender, instance, **kwargs): + from .api.serializers import BatchCommandSerializer + + batch_data = BatchCommandSerializer(instance).data + batch_data["type"] = "batch_status" + affected_devices = instance.affected_devices + batch_data.pop("skipped_devices", None) + skipped_count = instance.skipped_count + batch_data["affected_devices"] = affected_devices + batch_data["total_rows"] = affected_devices + skipped_count + batch_data["skipped_count"] = skipped_count + batch_data["skipped_preview"] = instance.get_skipped_preview() + send_batch_command_websocket_event(f"config.batchcommand-{instance.pk}", batch_data) diff --git a/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py b/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py index 8059c39c4..4f04b4e2c 100644 --- a/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py +++ b/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py @@ -2,7 +2,6 @@ import uuid -import django import django.core.serializers.json import django.db.migrations.operations.special import django.db.models.deletion @@ -72,11 +71,7 @@ class Migration(migrations.Migration): ( "type", models.CharField( - choices=( - openwisp_controller.connection.commands.COMMAND_CHOICES - if django.VERSION < (5, 0) - else openwisp_controller.connection.commands.get_command_choices # noqa: E501 - ), + choices=openwisp_controller.connection.commands.get_command_choices, # noqa: E501 max_length=16, ), ), @@ -94,11 +89,12 @@ class Migration(migrations.Migration): blank=True, default=dict, help_text=( - "Maps device UUIDs to validation error messages for " - "devices that were skipped during command creation." + "Maps device UUIDs to the name of the device and the " + "validation error that caused it to be skipped during " + "command creation." ), null=True, - verbose_name="Skipped devices", + verbose_name="skipped devices", ), ), ( 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..a2ec3a730 --- /dev/null +++ b/openwisp_controller/connection/static/connection/css/batch-command.css @@ -0,0 +1,312 @@ +/* ==== Mass Command Change Page CSS ==== */ +#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; + /* the output column takes whatever these three leave over */ + --device-column: 23%; + --status-column: 15%; + --modified-column: 15%; +} +.results-table th:nth-child(1), +.results-table td:nth-child(1):not(.empty-results) { + width: var(--device-column); +} +.results-table th:nth-child(2), +.results-table td:nth-child(2) { + width: var(--status-column); + white-space: nowrap; +} +.results-table th:nth-child(4), +.results-table td:nth-child(4) { + width: var(--modified-column); + white-space: nowrap; +} +#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 pre { + white-space: pre-wrap; + overflow-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; +} + +/* 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; +} + +/* ==== Stepper CSS ==== */ +.stepper { + --step-active-bg: var(--ow-color-primary); + --step-active-text: var(--ow-color-white); + --step-inactive-bg: var(--ow-color-fg-light); + --step-inactive-text: var(--ow-color-fg-dark); + --arrow-color: var(--ow-color-fg-dark); + + display: inline-flex; + align-items: stretch; + overflow: hidden; + margin-bottom: 1.75rem; +} +.stepper-step { + align-items: center; + cursor: pointer; + display: flex; + gap: 0.75rem; + padding: 0.875rem 1.5rem 0.875rem 0; + position: relative; +} +.stepper-badge { + align-items: center; + border-radius: 50%; + display: flex; + flex-shrink: 0; + font-size: 0.8125rem; + font-weight: 600; + height: 2rem; + justify-content: center; + position: relative; + width: 2rem; + z-index: 1; +} +.stepper-step.active .stepper-badge { + background-color: var(--step-active-bg); + color: var(--step-active-text); +} +.stepper-step.inactive .stepper-badge { + background-color: var(--step-inactive-bg); + color: var(--step-inactive-text); +} +.stepper-label { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} +.stepper-label-text { + font-size: 0.875rem; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; +} +.stepper-step.active .stepper-label-text { + color: var(--step-active-bg); + font-weight: 600; +} +.stepper-step.inactive .stepper-label-text { + color: var(--step-inactive-text); + font-weight: 500; +} +.stepper-divider { + align-items: center; + display: flex; + flex-shrink: 0; + justify-content: center; + padding: 0.5rem 1rem 0.5rem 0; +} +.stepper-arrow { + color: var(--arrow-color); + display: block; + flex-shrink: 0; + height: 1rem; + width: 1rem; +} + +/* ==== Confirm Page CSS ==== */ +.confirm-command .stepper { + margin-bottom: 1.5rem; +} +.command-summary { + margin-bottom: 1.5rem; +} +.command-summary .form-row { + padding: 8px 12px; +} +.devices-heading { + margin-bottom: 1rem; +} +.confirm-command #changelist { + margin-top: 0; +} +/* no admin actions on this changelist, so the row would be empty */ +.confirm-command #changelist .actions { + display: none; +} +/* Django styles its own rows with "tr:has(.action-select:checked)", which cannot match here */ +.confirm-command #changelist tbody tr:has(.device-checkbox:checked) { + background-color: var(--selected-row); +} +@media (forced-colors: active) { + .confirm-command #changelist tbody tr:has(.device-checkbox:checked) { + background-color: SelectedItem; + } +} +.confirm-command #result_list th.column-select_device, +.confirm-command #result_list td.field-select_device { + text-align: center; + width: 2rem; +} +#main .execute-form .submit-row { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} +.execute-batch-command .jsoneditor-wrapper > fieldset.module { + background: none; + border: none; + margin: 0; + padding: 0; +} +.execute-batch-command .jsoneditor-wrapper > fieldset > h2, +.execute-batch-command .jsoneditor h3 { + display: none !important; +} +.execute-batch-command.no-command-type .jsoneditor-wrapper { + display: none; +} +.execute-batch-command #main .jsoneditor-wrapper div.jsoneditor .form-row { + display: flex; + flex-wrap: wrap; + padding: 15px; +} +.execute-batch-command .jsoneditor-wrapper div.jsoneditor label { + font-weight: bold; + margin-left: 0; +} +.execute-batch-command .jsoneditor .form-row > .help { + flex-basis: 100%; +} +.execute-batch-command .jsoneditor .errorlist { + display: none; +} +.execute-batch-command .jsoneditor.command-errors .errorlist { + display: block; +} +.execute-batch-command .jsoneditor .form-row.errors input, +.execute-batch-command .jsoneditor .form-row.errors select, +.execute-batch-command .jsoneditor .form-row.errors textarea { + border-color: var(--border-color); +} +.execute-batch-command .jsoneditor.command-errors .form-row.errors input, +.execute-batch-command .jsoneditor.command-errors .form-row.errors select, +.execute-batch-command .jsoneditor.command-errors .form-row.errors textarea { + border-color: var(--ow-color-danger); +} +.execute-batch-command .jsoneditor .form-row > ul.errorlist { + order: -1; + flex-basis: calc(100% - 160px); + margin-left: 160px; + padding-left: 10px; +} +.execute-batch-command .form-row.field-input { + display: none; +} +.execute-batch-command .form-row.field-input.errors { + display: block !important; +} +.execute-batch-command .form-row.field-input.errors .flex-container { + display: none; +} +#main .form-row .ow-text-field { + width: 320px; +} diff --git a/openwisp_controller/connection/static/connection/css/command-inline.css b/openwisp_controller/connection/static/connection/css/command-inline.css index 0da80c341..c44eda750 100644 --- a/openwisp_controller/connection/static/connection/css/command-inline.css +++ b/openwisp_controller/connection/static/connection/css/command-inline.css @@ -25,6 +25,9 @@ li.commands:not(.recent) { line-height: 1.5em; white-space: pre; } +#command_set-2-group .form-row.field-batch_command:not(:has(a)) { + display: none; +} .object-updated { transition: background-color 3s ease; } 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..a2e3d3ffe --- /dev/null +++ b/openwisp_controller/connection/static/connection/js/batch-command.js @@ -0,0 +1,395 @@ +"use strict"; + +const DEFAULT_PER_PAGE = 20; +const FILTERED_REFRESH_DELAY = 1000; +const DEVICE_URL_PLACEHOLDER = "00000000-0000-0000-0000-000000000000"; +let filteredRefreshTimeout = null; + +django.jQuery(function ($) { + const batchCommandWebSocket = new ReconnectingWebSocket(getWebSocketUrl(), null, { + debug: false, + automaticOpen: false, + timeoutInterval: 7000, + }); + batchCommandWebSocket.addEventListener("open", function () { + requestCurrentState($, batchCommandWebSocket); + }); + batchCommandWebSocket.addEventListener("message", function (e) { + const data = JSON.parse(e.data); + if (data.type === "command_update") { + handleCommandMessage($, data, batchCommandWebSocket); + } else if (data.type === "batch_status") { + handleBatchStatusMessage($, data, batchCommandWebSocket); + } else if (data.type === "batch_state") { + handleBatchStateMessage($, data); + } + }); + batchCommandWebSocket.open(); + // exposed so that a reconnect can be triggered from the browser tests + window.batchCommandWebSocket = batchCommandWebSocket; +}); + +function getWebSocketUrl() { + return `${getWebSocketProtocol()}${owControllerApiHost.host}/ws/controller/batch-command/${batchCommandId}`; +} + +function getWebSocketProtocol() { + let protocol = "ws://"; + if (window.location.protocol === "https:") { + protocol = "wss://"; + } + return protocol; +} + +function requestCurrentState($, websocket) { + if (websocket.readyState !== WebSocket.OPEN) { + return; + } + try { + websocket.send( + JSON.stringify({ + type: "request_current_state", + batch_id: batchCommandId, + page: getCurrentPage($), + filters: getActiveFilters(), + }), + ); + } catch (error) { + console.error("Error requesting current batch state:", error); + } +} + +function handleCommandMessage($, data, websocket) { + updateTotals($, data.affected_devices, data.total_rows); + renderCommand($, data); + announceUpdate( + $, + interpolate( + gettext("Updated %(device)s: %(status)s"), + { + device: data.device_name, + status: getStatusLabel(data.status), + }, + true, + ), + ); + if (hasActiveFilters()) { + scheduleFilteredRefresh($, websocket); + } +} + +function scheduleFilteredRefresh($, websocket) { + if (!websocket || filteredRefreshTimeout) { + return; + } + filteredRefreshTimeout = setTimeout(function () { + filteredRefreshTimeout = null; + requestCurrentState($, websocket); + }, FILTERED_REFRESH_DELAY); +} + +function getStatusLabel(status) { + const element = document.getElementById("batch-status-labels"); + const labels = element ? JSON.parse(element.textContent) : {}; + return labels[status] || status; +} + +function handleBatchStatusMessage($, data, websocket) { + const $status = $(".field-colored_status .readonly .command-status"); + if ($status.length && data.status) { + $status + .removeClass() + .addClass("command-status " + data.status) + .text(getStatusLabel(data.status)); + } + updateSkippedDevices($, data); + updateTotals($, data.affected_devices, data.total_rows); + if (data.status) { + announceUpdate( + $, + interpolate( + gettext("Mass command status: %(status)s"), + { + status: getStatusLabel(data.status), + }, + true, + ), + ); + } + const $table = $("#result_list"); + if ( + websocket && + data.skipped_count && + data.skipped_count !== $table.data("skippedCount") + ) { + $table.data("skippedCount", data.skipped_count); + requestCurrentState($, websocket); + } +} + +function updateSkippedDevices($, data) { + if (!data.skipped_count) { + return; + } + let $list = $(".field-display_skipped_devices .skipped-devices-list"); + if (!$list.length) { + const $readonly = $(".field-display_skipped_devices .readonly"); + if (!$readonly.length) { + return; + } + $list = $("
").addClass("skipped-devices-list"); + $readonly.empty().append($list); + } + $list.empty().append(document.createTextNode(String(data.skipped_count))); + const rows = data.skipped_preview || []; + rows.forEach(function (row, index) { + if (index === rows.length - 1 && rows.length < data.skipped_count) { + $list.append($("
")).append(document.createTextNode("\u2026")); + } + $list + .append($("
")) + .append(document.createTextNode(row.device_name + ": " + row.output)); + }); +} + +function handleBatchStateMessage($, data) { + if (data.page) { + $("#result_list").attr("data-current-page", data.page); + } + if (data.batch_status) { + handleBatchStatusMessage($, data.batch_status); + } + updateTotals( + $, + data.batch_status ? data.batch_status.affected_devices : null, + data.total_rows, + true, + ); + if (!data.commands || !Array.isArray(data.commands)) { + return; + } + // the snapshot is built with the filters this page is showing, so it is the + // authoritative content of the table: rows missing from it no longer belong + reconcileRows($, data.commands); +} + +function reconcileRows($, commands) { + const devices = {}; + commands.forEach(function (command) { + devices[String(command.device)] = true; + const $row = $("#batch-command-row-" + command.device); + if ($row.length) { + updateRow($, $row, command); + } else { + insertRow($, command); + } + }); + $("#result_list tbody tr[data-device-pk]").each(function () { + if (!devices[String($(this).data("device-pk"))]) { + $(this).remove(); + } + }); + if (!commands.length && !$("#result_list td.empty-results").length) { + $("#result_list tbody").append( + $("").append( + $("") + .attr("colspan", 4) + .addClass("empty-results") + .text(gettext("No commands found.")), + ), + ); + } +} + +function announceUpdate($, message) { + $("#batch-command-live-status").text(message); +} + +function renderCommand($, data) { + const $row = $("#batch-command-row-" + data.device); + if ($row.length) { + updateRow($, $row, data); + } else if (belongsOnCurrentPage($, data)) { + insertRow($, data); + } + // otherwise the row is on another page, the server renders it there +} + +// The server sends the position of newly created results only, the page is +// worked out here from the size the table was rendered with: this keeps the +// first page at "per page" rows while the paginator keeps growing. +function belongsOnCurrentPage($, data) { + // with a filter on, the pushed totals are unfiltered and page boundaries + // cannot be worked out + if (hasActiveFilters()) { + return false; + } + if (data.index == null) { + return false; + } + const renderedRows = $("#result_list tbody tr").not(":has(td.empty-results)").length; + if (renderedRows >= getPerPage($)) { + return false; + } + // the paginator is 1-based, so position 0 is on page 1 + return Math.floor(data.index / getPerPage($)) + 1 === getCurrentPage($); +} + +function updateRow($, $row, data) { + const activeFilter = getActiveStatusFilter($); + if (activeFilter && activeFilter !== data.status) { + $row.remove(); + return; + } + $row + .find(".command-status") + .removeClass() + .addClass("command-status " + data.status) + .text(getStatusLabel(data.status)); + $row.find(".command-output pre").text(data.output || "-"); + $row.find("td:last-child").text(data.modified_display || "-"); +} + +function insertRow($, data) { + $("#result_list td.empty-results").closest("tr").remove(); + const $tableBody = $("#result_list tbody"); + const rowClass = $tableBody.find("tr").length % 2 === 0 ? "row1" : "row2"; + const $row = $("").attr({ + id: "batch-command-row-" + data.device, + "data-device-pk": data.device, + class: rowClass, + }); + if (data.is_skipped) { + $row.append( + $("").append( + $("").addClass("device-name-disabled").text(data.device_name), + ), + ); + } else { + $row.append( + $("").append( + $("") + .attr({ + href: getDeviceChangeUrl($, data.device), + class: "device-link", + }) + .text(data.device_name), + ), + ); + } + $row.append( + $("").append( + $("") + .addClass("command-status " + data.status) + .text(getStatusLabel(data.status)), + ), + ); + $row.append( + $("") + .addClass("command-output") + .append($("
").text(data.output || "-")),
+  );
+  $row.append($("").text(data.modified_display || "-"));
+  $tableBody.append($row);
+}
+
+function updateTotals($, affectedDevices, totalRows, filtered) {
+  if (affectedDevices != null) {
+    const $affected = $(".field-affected_devices .readonly");
+    if ($affected.length) {
+      $affected.text(String(affectedDevices));
+    }
+  }
+  // pushed totals are not filtered, only the reconnect snapshot is
+  if (totalRows == null || (!filtered && hasActiveFilters())) {
+    return;
+  }
+  const $paginator = $(".results-container .paginator");
+  if ($paginator.length) {
+    $paginator.text(
+      interpolate(ngettext("%s command", "%s commands", totalRows), [totalRows]),
+    );
+  }
+  renderPagination($, totalRows);
+}
+
+function renderPagination($, totalRows) {
+  const currentPage = getCurrentPage($);
+  const perPage = getPerPage($);
+  const totalPages = Math.max(1, Math.ceil(totalRows / perPage));
+  $(".results-container .pagination").remove();
+  if (totalPages <= 1) {
+    return;
+  }
+  const params = new URLSearchParams(window.location.search);
+  params.delete("page");
+  const baseQuery = params.toString();
+  const buildHref = function (page) {
+    return "?" + (baseQuery ? baseQuery + "&page=" + page : "page=" + page);
+  };
+  const $stepLinks = $("").addClass("step-links");
+  if (currentPage > 1) {
+    $stepLinks.append(
+      $("")
+        .attr("href", buildHref(currentPage - 1))
+        .text(gettext("Previous")),
+    );
+  }
+  $stepLinks.append(
+    $("")
+      .addClass("current-page")
+      .text(
+        interpolate(
+          gettext("Page %(current)s of %(total)s"),
+          { current: currentPage, total: totalPages },
+          true,
+        ),
+      ),
+  );
+  if (currentPage < totalPages) {
+    $stepLinks.append(
+      $("")
+        .attr("href", buildHref(currentPage + 1))
+        .text(gettext("Next")),
+    );
+  }
+  $("
").addClass("pagination").append($stepLinks).appendTo(".results-container"); +} + +function getDeviceChangeUrl($, devicePk) { + const template = $("#result_list").attr("data-device-url"); + if (!template) { + return "#"; + } + return template.replace(DEVICE_URL_PLACEHOLDER, devicePk) + "#command_set-2-group"; +} + +function getActiveStatusFilter($) { + return $("#result_list").attr("data-active-status") || ""; +} + +function getActiveFilters() { + const params = new URLSearchParams(window.location.search); + const filters = {}; + ["q", "status", "location_id", "group_id", "organization_id"].forEach( + function (name) { + filters[name] = params.get(name) || ""; + }, + ); + return filters; +} + +function hasActiveFilters() { + const filters = getActiveFilters(); + return Object.keys(filters).some(function (name) { + return !!filters[name]; + }); +} + +function getCurrentPage($) { + return parseInt($("#result_list").attr("data-current-page"), 10) || 1; +} + +function getPerPage($) { + return parseInt($("#result_list").attr("data-per-page"), 10) || DEFAULT_PER_PAGE; +} diff --git a/openwisp_controller/connection/static/connection/js/execute-command.js b/openwisp_controller/connection/static/connection/js/execute-command.js new file mode 100644 index 000000000..bc5863766 --- /dev/null +++ b/openwisp_controller/connection/static/connection/js/execute-command.js @@ -0,0 +1,265 @@ +"use strict"; + +const EXCLUDED_STORAGE_PREFIX = "ow-batch-command-excluded:"; +const WIZARD_SELECTS = "#id_type, #id_organization, #id_group, #id_location"; +const COMMAND_EDITOR_ID = "id_input_jsoneditor"; + +django.jQuery(function ($) { + initExecuteCommandForm($); + initDeviceSelection($); +}); + +function initExecuteCommandForm($) { + const $typeSelect = $("#id_type"); + if (!$typeSelect.length) { + return; + } + const $form = $typeSelect.closest("form"); + + clearAbandonedExclusions(); + + $(WIZARD_SELECTS).select2({ + theme: "default", + placeholder: gettext("Select an option"), + allowClear: true, + width: "resolve", + }); + + initOrganizationScope($); + initCommandInput($, $typeSelect); + + // admin pages are served no-store, so a back navigation restores the field + // values after select2 has already rendered its labels + $(window).on("pageshow", function () { + $(WIZARD_SELECTS).each(function () { + const $field = $(this); + if ($field.data("select2")) { + $field.trigger("change.select2"); + } + }); + }); + + $form.on("submit", function (event) { + clearFieldErrors($); + const type = $typeSelect.val(); + let hasError = false; + if (!type) { + showFieldError( + $typeSelect.closest(".form-row"), + gettext("This field is required."), + ); + hasError = true; + } + if (!$.trim($("#id_label").val() || "")) { + showFieldError( + $("#id_label").closest(".form-row"), + gettext("This field is required."), + ); + hasError = true; + } + if (showCommandErrors($)) { + hasError = true; + } + if (hasError) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + }); +} + +// mirrors checkInputIsValid() in commands.js: the editor renders its errors as +// soon as it is built, so they are kept hidden until the form is submitted +function showCommandErrors($) { + const editor = (django._jsonEditors || {})[COMMAND_EDITOR_ID]; + if (!editor) { + return false; + } + const errors = editor.validate(); + // a field is redisplayed only when it was edited or when "show_errors" + // changed since the last call, "always" skips both checks + editor.options.show_errors = "always"; + editor.root.showValidationErrors(errors); + $("#" + COMMAND_EDITOR_ID).addClass("command-errors"); + return errors.length > 0; +} + +// with no type the editor is handed the whole schema map and renders it as a +// generic root object +function initCommandInput($, $typeSelect) { + function toggle() { + $(document.body).toggleClass("no-command-type", !$typeSelect.val()); + // the container is reused, its errors belong to the previous type + $("#" + COMMAND_EDITOR_ID).removeClass("command-errors"); + } + + $typeSelect.on("change", toggle); + toggle(); +} + +function initOrganizationScope($) { + const $organization = $("#id_organization"); + const fields = ["#id_group", "#id_location"]; + fields.forEach(function (selector) { + $(selector).data("allOptions", $(selector).find("option").clone()); + }); + + function applyScope() { + const organizationId = $organization.val() || ""; + fields.forEach(function (selector) { + const $field = $(selector); + const current = $field.val(); + const $options = $field.data("allOptions").filter(function () { + const value = $(this).attr("value"); + return ( + !value || + !organizationId || + $(this).attr("data-organization-id") === organizationId + ); + }); + $field.empty().append($options.clone()); + $field.val($options.filter('[value="' + current + '"]').length ? current : ""); + $field.trigger("change.select2"); + }); + } + + $organization.on("change", applyScope); + applyScope(); +} + +function initDeviceSelection($) { + const $form = $("#execute-form"); + if (!$form.length) { + return; + } + // sessionStorage outlives the wizard, so the key is namespaced by its token + const storageKey = EXCLUDED_STORAGE_PREFIX + ($form.data("wizard-token") || ""); + const $table = $("#result_list"); + const $excludedField = $("#id_excluded"); + const $count = $("#selected-count"); + const $countLabel = $("#selected-count-label"); + const $button = $("#execute-button"); + const totalDevices = parseInt($form.data("total-devices"), 10) || 0; + const excluded = getStoredExclusions($, storageKey); + + function updateSelectionSummary() { + const pks = Object.keys(excluded); + const selected = Math.max(totalDevices - pks.length, 0); + $excludedField.val(pks.join(",")); + setStoredExclusions(storageKey, pks); + $count.text(selected); + $countLabel.text(ngettext("device", "devices", selected)); + $button.text( + interpolate(ngettext("Execute on %s device", "Execute on %s devices", selected), [ + selected, + ]), + ); + $button.prop("disabled", selected === 0); + updateSelectAllCheckbox(); + } + + function updateSelectAllCheckbox() { + const $checkboxes = $table.find(".device-checkbox"); + $("#select-all-devices").prop( + "checked", + $checkboxes.length > 0 && + $checkboxes.filter(":checked").length === $checkboxes.length, + ); + } + + $table.on("change", ".device-checkbox", function () { + const pk = $(this).val(); + if (this.checked) { + delete excluded[pk]; + } else { + excluded[pk] = true; + } + updateSelectionSummary(); + }); + + // devices the user cannot see are never toggled implicitly + $table.on("change", "#select-all-devices", function () { + const checked = this.checked; + $table.find(".device-checkbox").each(function () { + const $checkbox = $(this); + if ($checkbox.prop("checked") !== checked) { + $checkbox.prop("checked", checked).trigger("change"); + } + }); + }); + + $form.on("submit", function () { + // guards against a double click creating two mass commands + $button.prop("disabled", true); + }); + + renderSelectAllCheckbox($, $table); + restoreDeviceCheckboxes($, $table, excluded); + updateSelectionSummary(); +} + +function renderSelectAllCheckbox($, $table) { + const $header = $table.find("thead th").first(); + if (!$header.length || $header.find("#select-all-devices").length) { + return; + } + $header.append( + $("").attr({ + type: "checkbox", + id: "select-all-devices", + title: gettext("Select all devices on this page"), + }), + ); +} + +function restoreDeviceCheckboxes($, $table, excluded) { + $table.find(".device-checkbox").each(function () { + const $checkbox = $(this); + $checkbox.prop("checked", !excluded[$checkbox.val()]); + }); +} + +// paging the device table is an ordinary page load, which would forget them +function getStoredExclusions($, storageKey) { + const stored = {}; + try { + const raw = window.sessionStorage.getItem(storageKey); + $.each(raw ? JSON.parse(raw) : [], function (index, pk) { + stored[pk] = true; + }); + } catch (error) { + // private browsing modes can make sessionStorage unavailable + } + return stored; +} + +function setStoredExclusions(storageKey, pks) { + try { + window.sessionStorage.setItem(storageKey, JSON.stringify(pks)); + } catch (error) { + // see getStoredExclusions() + } +} + +function clearAbandonedExclusions() { + try { + const storage = window.sessionStorage; + for (let i = storage.length - 1; i >= 0; i--) { + const key = storage.key(i); + if (key && key.indexOf(EXCLUDED_STORAGE_PREFIX) === 0) { + storage.removeItem(key); + } + } + } catch (error) { + // see getStoredExclusions() + } +} + +function clearFieldErrors($) { + $(".form-row.errors").removeClass("errors"); + $(".form-row .errorlist").not(".jsoneditor .errorlist").remove(); +} + +function showFieldError($row, message) { + $row.addClass("errors"); + $row.prepend('
  • ' + message + "
"); +} diff --git a/openwisp_controller/connection/tasks.py b/openwisp_controller/connection/tasks.py index 6f61d4d0d..8f6fff490 100644 --- a/openwisp_controller/connection/tasks.py +++ b/openwisp_controller/connection/tasks.py @@ -84,10 +84,12 @@ def launch_command(command_id): except SoftTimeLimitExceeded: command.status = "failed" command._add_output(_("Background task time limit exceeded.")) + command._clean_sensitive_info() command._save_without_resurrecting() except CommandTimeoutException as e: command.status = "failed" command._add_output(_(f"The command took longer than expected: {e}")) + command._clean_sensitive_info() command._save_without_resurrecting() except Exception as e: logger.exception( @@ -95,6 +97,7 @@ def launch_command(command_id): ) command.status = "failed" command._add_output(_(f"Internal system error: {e}")) + command._clean_sensitive_info() command._save_without_resurrecting() 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..8f86a4ceb --- /dev/null +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html @@ -0,0 +1,186 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static admin_list ow_tags %} + +{% block extrahead %} +{{ block.super }} + + + + +{{ status_labels|json_script:"batch-status-labels" }} +{% endblock %} + +{% block content %} +{{ block.super }} + +

{% trans "Commands" %}

+ +{% if filter_specs %} +
+{% endif %} + +
+
+ + + + + + + {% for param, value in request.GET.items %} + {% if param != 'q' and param != 'page' %} + + {% endif %} + {% endfor %} +
+
+ +
+ {# Allows screen readers to announce live batch progress without reading the whole result table. #} +
+ + + + + + + + + + + {% for command in commands %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Device" %}{% trans "Status" %}{% trans "Output" %}{% trans "Modified" %}
+ {% if command.is_skipped %} + {{ command.device_name }} + {% else %} + + {{ command.device_name }} + + {% endif %} + + {{ command.status_display }} + +
{{ command.output|default:"-" }}
+
{{ command.modified_display|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 %} diff --git a/openwisp_controller/connection/templates/admin/connection/batch_command/confirm_command.html b/openwisp_controller/connection/templates/admin/connection/batch_command/confirm_command.html new file mode 100644 index 000000000..57e27225d --- /dev/null +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/confirm_command.html @@ -0,0 +1,133 @@ +{% extends device_changelist_template|default:"admin/change_list.html" %} +{% load i18n admin_urls static %} + +{% comment %} +step two: extends the changelist template of the registered Device admin, +so the assets of columns added by other modules load too +see BatchCommandAdmin.get_device_changelist_template() +{% endcomment %} + +{% block extrastyle %} +{{ block.super }} + + +{% endblock %} + +{% block bodyclass %}{{ block.super }} confirm-command{% endblock %} + +{# hides the changelist's "Add device" button #} +{% block object-tools %}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} + + +
+

{% trans 'Summary' %}

+
+
+ +
{{ command_type_display }}
+
+
+ {% if command_description %} +
+
+ +
{{ command_description }}
+
+
+ {% endif %} +
+
+ +
{{ wizard.label }}
+
+
+
+
+ +
{{ targets_display }}
+
+
+
+
+ +
+ {% blocktrans trimmed count counter=device_count %} + {{ counter }} device + {% plural %} + {{ counter }} devices + {% endblocktrans %} +
+
+
+
+
+ +
{{ request.user }} — {% now "DATETIME_FORMAT" %}
+
+
+
+ +
+

{% trans 'Affected devices' %}

+
+ +{% comment %} +renders the changelist: the device table, its pagination, and the
wrapping both +HTML does not allow nested forms, so the execute form below is a sibling of that one: +nesting it made the browser drop the opening tag, leaving the button outside any form +{% endcomment %} +{{ block.super }} + + + {% csrf_token %} + + +
+ {% trans 'Back' %} + +
+
+{% endblock %} + +{% block footer %} +{{ block.super }} + + +{% endblock %} diff --git a/openwisp_controller/connection/templates/admin/connection/batch_command/execute_command.html b/openwisp_controller/connection/templates/admin/connection/batch_command/execute_command.html new file mode 100644 index 000000000..63ecc78af --- /dev/null +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/execute_command.html @@ -0,0 +1,82 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% comment %} +step one: collects the command details and targets, saves them in the session, +then redirects to the confirm page +{% endcomment %} + +{% block extrastyle %} +{{ block.super }} + + +{{ media.css }} +{% endblock %} + +{% block extrahead %} +{{ block.super }} + +{{ media.js }} +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} execute-batch-command{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} + + + {% if form.non_field_errors %} +

{{ form.non_field_errors|join:" " }}

+ {% endif %} + + {% comment %} + this is not an admin change form, so there are no admin fieldsets to render: + the sections and the order of their fields come from + BatchCommandExecutionForm.fieldsets(), and form_row.html gives each field + the markup of the admin, so the page is styled like any other admin form + {% endcomment %} + {% for title, fields in form.fieldsets %} +
+

{{ title }}

+ {% for field in fields %} + {% include "admin/connection/batch_command/form_row.html" %} + {% endfor %} +
+ {% endfor %} + +
+ {% trans "Cancel" %} + +
+
+
+{% endblock %} diff --git a/openwisp_controller/connection/templates/admin/connection/batch_command/form_row.html b/openwisp_controller/connection/templates/admin/connection/batch_command/form_row.html new file mode 100644 index 000000000..56f121803 --- /dev/null +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/form_row.html @@ -0,0 +1,22 @@ +{% comment %} +renders one field of the wizard with the markup of the Django admin, so that +the admin styles, the errors and the help text look like they do on any other +admin form: the wizard uses a plain form instead of an admin fieldset, so +"admin/includes/fieldset.html" cannot be used. + +adapted from that template, keeping only what one field needs: the loop over +the fieldset and its lines, the rows holding several fields, the checkbox and +the read only rendering are left out. +{% endcomment %} +
+ {{ field.errors }} +
+ {{ field.label_tag }} + {{ field }} +
+ {% if field.help_text %} +
+
{{ field.help_text }}
+
+ {% endif %} +
diff --git a/openwisp_controller/connection/tests/pytest.py b/openwisp_controller/connection/tests/pytest.py index d7f7f5659..f6812fc82 100644 --- a/openwisp_controller/connection/tests/pytest.py +++ b/openwisp_controller/connection/tests/pytest.py @@ -1,18 +1,28 @@ from unittest import mock +from uuid import uuid4 import pytest from channels.db import database_sync_to_async from channels.testing import WebsocketCommunicator from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser, Permission from django.utils import timezone from django.utils.module_loading import import_string from swapper import load_model from openwisp_controller.connection.tests.utils import CreateCommandMixin +from .. import handlers +from ..channels.consumers import BatchCommandConsumer +from ..utils import format_localized_datetime from .test_models import BaseTestModels +User = get_user_model() Command = load_model("connection", "Command") +Device = load_model("config", "Device") +BatchCommand = load_model("connection", "BatchCommand") +OrganizationUser = load_model("openwisp_users", "OrganizationUser") @pytest.mark.asyncio @@ -107,3 +117,408 @@ async def test_multiple_connections_receive_updates_with_redis( assert response2 == expected_response await communicator1.disconnect() await communicator2.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +class TestBatchCommandConsumer(BaseTestModels, CreateCommandMixin): + application = import_string(getattr(settings, "ASGI_APPLICATION")) + path = "ws/controller/batch-command" + + async def _connect(self, pk, user=None): + communicator = WebsocketCommunicator( + BatchCommandConsumer.as_asgi(), f"{self.path}/{pk}" + ) + communicator.scope["url_route"] = {"kwargs": {"pk": str(pk)}} + if user is not None: + communicator.scope["user"] = user + connected, _ = await communicator.connect() + return communicator, connected + + @database_sync_to_async + def _create_batch(self, organization=None, **kwargs): + return self._create_batch_command(organization=organization, **kwargs) + + @database_sync_to_async + def _create_staff(self, username, org=None, codenames=(), is_staff=True): + user = self._create_user( + username=username, email=f"{username}@test.com", is_staff=is_staff + ) + user.user_permissions.set(Permission.objects.filter(codename__in=codenames)) + if org is not None: + OrganizationUser.objects.create(user=user, organization=org, is_admin=True) + return User.objects.get(pk=user.pk) + + @database_sync_to_async + def _set_skipped(self, batch, count): + batch.skipped_devices = { + str(uuid4()): {"name": f"skipped{index}", "error": f"error {index}"} + for index in range(count) + } + batch.save(update_fields=["skipped_devices"]) + batch.refresh_from_db(fields=["skipped_devices"]) + return batch.skipped_devices + + async def test_batch_command_consumer_authorization(self, admin_user, admin_client): + async def connect_through_route(batch): + session_id = admin_client.cookies["sessionid"].value + communicator = WebsocketCommunicator( + self.application, + path=f"{self.path}/{batch.pk}", + headers=[(b"cookie", f"sessionid={session_id}".encode("ascii"))], + ) + connected, _ = await communicator.connect() + return communicator, connected + + org = await database_sync_to_async(self._get_org)() + org2 = await database_sync_to_async(self._create_org)(name="org2", slug="org2") + batch = await self._create_batch(organization=org) + communicator, connected = await connect_through_route(batch) + assert connected is True + await communicator.disconnect() + communicator, connected = await self._connect(batch.pk) + assert connected is False + communicator, connected = await self._connect(batch.pk, AnonymousUser()) + assert connected is False + communicator, connected = await self._connect(batch.pk, admin_user) + assert connected is True + await communicator.disconnect() + manager = await self._create_staff( + "manager", org=org, codenames=["view_batchcommand", "add_batchcommand"] + ) + communicator, connected = await self._connect(batch.pk, manager) + assert connected is True + await communicator.disconnect() + add_only = await self._create_staff( + "add-only", org=org, codenames=["add_batchcommand"] + ) + communicator, connected = await self._connect(batch.pk, add_only) + assert connected is False + outsider = await self._create_staff( + "outsider", org=org2, codenames=["view_batchcommand"] + ) + communicator, connected = await self._connect(batch.pk, outsider) + assert connected is False + non_staff = await self._create_staff( + "non-staff", org=org, codenames=["view_batchcommand"], is_staff=False + ) + communicator, connected = await self._connect(batch.pk, non_staff) + assert connected is False + # a batch without an organization spans every organization + shared_batch = await self._create_batch(organization=None) + communicator, connected = await self._connect(shared_batch.pk, manager) + assert connected is False + communicator, connected = await self._connect(shared_batch.pk, admin_user) + assert connected is True + await communicator.disconnect() + communicator, connected = await self._connect(uuid4(), manager) + assert connected is False + # a rejected connection was never added to a group + await communicator.disconnect() + + @mock.patch("paramiko.SSHClient.connect") + async def test_batch_command_consumer_current_state( + self, mocked_connect, admin_user + ): + org = await database_sync_to_async(self._get_org)() + device_conn = await database_sync_to_async(self._create_device_connection)() + batch = await self._create_batch(organization=org) + with mock.patch.object(Command, "_schedule_command"): + command = await database_sync_to_async(Command.objects.create)( + batch_command=batch, + device=device_conn.device, + connection=device_conn, + type="custom", + input={"command": "echo test"}, + status="success", + output="line one\nline two", + ) + skipped_pks = list(await self._set_skipped(batch, 3)) + await database_sync_to_async(batch.refresh_from_db)() + communicator, connected = await self._connect(batch.pk, admin_user) + assert connected is True + with mock.patch.object(BatchCommandConsumer, "per_page", 2): + await communicator.send_json_to( + {"type": "request_current_state", "page": 1} + ) + page1 = await communicator.receive_json_from() + assert page1["type"] == "batch_state" + assert page1["total_rows"] == 4 + batch_status = page1["batch_status"] + assert batch_status["status"] == batch.status + assert "status_display" not in batch_status + assert batch_status["affected_devices"] == 1 + assert batch_status["skipped_count"] == 3 + assert [row["device"] for row in batch_status["skipped_preview"]] == ( + skipped_pks + ) + assert "skipped_devices" not in batch_status + assert [row["device"] for row in page1["commands"]] == [ + str(command.device_id), + skipped_pks[0], + ] + command_row = page1["commands"][0] + assert command_row["device_name"] == device_conn.device.name + assert command_row["status"] == command.status + assert "status_display" not in command_row + assert command_row["output"] == "… line two" + assert ( + command_row["modified"] + == timezone.localtime(command.modified).isoformat() + ) + assert command_row["modified_display"] == format_localized_datetime( + command.modified + ) + assert "input" not in command_row + await communicator.send_json_to( + {"type": "request_current_state", "page": 2} + ) + page2 = await communicator.receive_json_from() + assert [row["device"] for row in page2["commands"]] == skipped_pks[1:] + assert all(row["is_skipped"] for row in page2["commands"]) + await communicator.send_json_to( + {"type": "request_current_state", "page": 3} + ) + clamped_page = await communicator.receive_json_from() + assert clamped_page["page"] == 2 + assert clamped_page["commands"] == page2["commands"] + await communicator.send_json_to( + { + "type": "request_current_state", + "page": 1, + "filters": {"group_id": str(uuid4())}, + } + ) + filtered = await communicator.receive_json_from() + assert filtered["total_rows"] == 0 + assert filtered["commands"] == [] + for field_name in ("group_id", "location_id", "organization_id"): + await communicator.send_json_to( + { + "type": "request_current_state", + "page": 1, + "filters": {field_name: "not-a-uuid"}, + } + ) + response = await communicator.receive_json_from() + assert response["commands"] == page1["commands"] + for value in ([1], "abc", 7): + await communicator.send_json_to( + {"type": "request_current_state", "page": 1, "filters": value} + ) + response = await communicator.receive_json_from() + assert response["type"] == "batch_state" + assert response["total_rows"] == page1["total_rows"] + for page in (0, -1, "abc", None): + await communicator.send_json_to( + {"type": "request_current_state", "page": page} + ) + response = await communicator.receive_json_from() + assert response["page"] == 1 + assert response["commands"] == page1["commands"] + await communicator.send_json_to( + {"type": "request_current_state", "page": 99} + ) + clamped = await communicator.receive_json_from() + assert clamped["page"] == 2 + assert clamped["commands"] == page2["commands"] + await communicator.disconnect() + communicator, connected = await self._connect(batch.pk, admin_user) + assert connected is True + await communicator.send_json_to({"type": "request_current_state"}) + response = await communicator.receive_json_from() + assert response["total_rows"] == 4 + assert [row["device"] for row in response["commands"]] == [ + str(command.device_id) + ] + skipped_pks + # a batch deleted after the connection was accepted is not answered + await database_sync_to_async(BatchCommand.objects.filter(pk=batch.pk).delete)() + await communicator.send_json_to({"type": "request_current_state"}) + assert await communicator.receive_nothing() is True + await communicator.disconnect() + + async def test_batch_command_consumer_access_revoked(self, admin_user): + org = await database_sync_to_async(self._get_org)() + batch = await self._create_batch(organization=org) + + def push_batch_status(): + batch.status = "in-progress" + batch.save(update_fields=["status"]) + + manager = await self._create_staff( + "revoked-manager", org=org, codenames=["view_batchcommand"] + ) + + communicator, connected = await self._connect(batch.pk, manager) + assert connected is True + await database_sync_to_async(manager.user_permissions.clear)() + await communicator.send_json_to({"type": "request_current_state", "page": 1}) + assert await communicator.receive_output() == {"type": "websocket.close"} + await communicator.disconnect() + + await database_sync_to_async(manager.user_permissions.set)( + await database_sync_to_async(list)( + Permission.objects.filter(codename="view_batchcommand") + ) + ) + communicator, connected = await self._connect(batch.pk, manager) + assert connected is True + await database_sync_to_async( + OrganizationUser.objects.filter(user=manager, organization=org).delete + )() + await database_sync_to_async(push_batch_status)() + assert await communicator.receive_output() == {"type": "websocket.close"} + await communicator.disconnect() + + async def test_batch_command_consumer_invalid_messages(self, admin_user): + org = await database_sync_to_async(self._get_org)() + batch = await self._create_batch(organization=org) + communicator, connected = await self._connect(batch.pk, admin_user) + assert connected is True + with mock.patch( + "openwisp_controller.connection.channels.consumers.logger" + ) as logger: + for message in ("not json", "[]", '"string"', "null"): + await communicator.send_to(text_data=message) + assert await communicator.receive_nothing() is True + for message in ({"type": "unknown"}, {}): + await communicator.send_json_to(message) + assert await communicator.receive_nothing() is True + await communicator.send_to(bytes_data=b"\x00\x01") + assert await communicator.receive_nothing() is True + assert logger.warning.call_count == 7 + await communicator.disconnect() + + @mock.patch("paramiko.SSHClient.connect") + async def test_batch_command_consumer_device_transferred_to_another_org( + self, mocked_connect, admin_user + ): + org = await database_sync_to_async(self._get_org)() + org2 = await database_sync_to_async(self._create_org)(name="org2", slug="org2") + device_conn = await database_sync_to_async(self._create_device_connection)() + batch = await self._create_batch(organization=org) + with mock.patch.object(Command, "_schedule_command"): + command = await database_sync_to_async(Command.objects.create)( + batch_command=batch, + device=device_conn.device, + connection=device_conn, + type="custom", + input={"command": "echo test"}, + status="success", + output="secret output", + ) + # the device is moved to another organization after its command + await database_sync_to_async( + Device.objects.filter(pk=device_conn.device_id).update + )(organization=org2, name="moved-to-org2") + manager = await self._create_staff( + "transfer-manager", org=org, codenames=["view_batchcommand"] + ) + + manager_socket, connected = await self._connect(batch.pk, manager) + assert connected is True + await manager_socket.send_json_to({"type": "request_current_state", "page": 1}) + state = await manager_socket.receive_json_from() + assert state["commands"] == [] + assert state["total_rows"] == 0 + + admin_socket, connected = await self._connect(batch.pk, admin_user) + assert connected is True + await admin_socket.send_json_to({"type": "request_current_state", "page": 1}) + state = await admin_socket.receive_json_from() + assert [row["device"] for row in state["commands"]] == [str(command.device_id)] + assert state["total_rows"] == 1 + + # the task reloads the command, so its device is read after the transfer + command = await database_sync_to_async(Command.objects.get)(pk=command.pk) + command.status = "failed" + await database_sync_to_async(command.save)() + update = await admin_socket.receive_json_from() + assert update["type"] == "command_update" + assert update["status"] == "failed" + assert "device_organization" not in update + received = [] + while not await manager_socket.receive_nothing(): + received.append(await manager_socket.receive_json_from()) + assert [row for row in received if row.get("type") == "command_update"] == [] + await manager_socket.disconnect() + await admin_socket.disconnect() + + @mock.patch("paramiko.SSHClient.connect") + async def test_batch_command_consumer_updates(self, mocked_connect, admin_user): + async def drain(communicator): + while not await communicator.receive_nothing(): + await communicator.receive_json_from() + + async def receive_until(communicator, message_type, limit=4): + for _ in range(limit): + message = await communicator.receive_json_from() + if message.get("type") == message_type: + return message + raise AssertionError(f"{message_type} was never received") + + org = await database_sync_to_async(self._get_org)() + device_conn = await database_sync_to_async(self._create_device_connection)() + batch = await self._create_batch(organization=org) + other_batch = await self._create_batch(organization=org, label="other") + communicator, connected = await self._connect(batch.pk, admin_user) + assert connected is True + other, other_connected = await self._connect(other_batch.pk, admin_user) + assert other_connected is True + watcher, watcher_connected = await self._connect(batch.pk, admin_user) + assert watcher_connected is True + with mock.patch.object(Command, "_schedule_command"): + command = await database_sync_to_async(Command.objects.create)( + batch_command=batch, + device=device_conn.device, + connection=device_conn, + type="custom", + input={"command": "echo test"}, + ) + created = await receive_until(communicator, "command_update") + assert created["id"] == str(command.pk) + assert created["status"] == command.status + assert "status_display" not in created + assert created["modified"] == timezone.localtime(command.modified).isoformat() + assert created["index"] == 0 + assert created["affected_devices"] == 1 + assert created["total_rows"] == 1 + assert created["device_name"] == device_conn.device.name + assert "input" not in created + assert await receive_until(watcher, "command_update") == created + assert await communicator.receive_nothing() is True + command.status = "success" + command.output = "done" + await database_sync_to_async(command.save)() + updated = await receive_until(communicator, "command_update") + assert updated["status"] == "success" + assert updated["output"] == "done" + assert "index" not in updated + await drain(communicator) + skipped = await self._set_skipped(batch, 2) + status = await receive_until(communicator, "batch_status") + assert status["skipped_count"] == 2 + assert [row["device_name"] for row in status["skipped_preview"]] == [ + row["name"] for row in skipped.values() + ] + assert status["affected_devices"] == 1 + assert status["total_rows"] == 3 + # updates are namespaced per batch + assert await other.receive_nothing() is True + with mock.patch.object( + handlers.layers, "get_channel_layer", side_effect=RuntimeError("no layer") + ), mock.patch.object(handlers, "logger") as logger, mock.patch.object( + Command, "_schedule_command" + ): + await database_sync_to_async(Command.objects.create)( + batch_command=batch, + device=device_conn.device, + connection=device_conn, + type="custom", + input={"command": "echo test"}, + ) + logger.exception.assert_called_once() + assert await database_sync_to_async(batch.batch_commands.count)() == 2 + await communicator.disconnect() + await watcher.disconnect() + await other.disconnect() diff --git a/openwisp_controller/connection/tests/test_admin.py b/openwisp_controller/connection/tests/test_admin.py index 379346bc2..c9b0061a0 100644 --- a/openwisp_controller/connection/tests/test_admin.py +++ b/openwisp_controller/connection/tests/test_admin.py @@ -1,19 +1,35 @@ import json from unittest.mock import patch +from uuid import uuid4 +from django.contrib import admin from django.contrib.auth.models import Permission -from django.test import TestCase, override_settings +from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.sessions.backends.cache import SessionStore +from django.core.exceptions import ValidationError +from django.db import connection as db_connection +from django.test import RequestFactory, TestCase, override_settings +from django.test.utils import CaptureQueriesContext from django.urls import reverse from swapper import load_model -from openwisp_controller.connection.commands import ORGANIZATION_ENABLED_COMMANDS +from openwisp_controller.connection.commands import ( + COMMANDS, + ORGANIZATION_COMMAND_SCHEMA, + ORGANIZATION_ENABLED_COMMANDS, + get_command_choices, +) from ... import settings as module_settings +from ...config.admin import DeviceAdmin from ...tests import _get_updated_templates_settings from ...tests.utils import TestAdminMixin +from ..admin import BatchCommandAdmin from ..connectors.ssh import Ssh +from ..filters import GroupFilter, LocationFilter, TypeFilter +from ..utils import format_localized_datetime from ..widgets import CredentialsSchemaWidget -from .utils import CreateConnectionsMixin +from .utils import BatchCommandMixin, CreateConnectionsMixin Template = load_model("config", "Template") Config = load_model("config", "Config") @@ -22,6 +38,10 @@ DeviceConnection = load_model("connection", "DeviceConnection") Command = load_model("connection", "Command") Group = load_model("openwisp_users", "Group") +DeviceGroup = load_model("config", "DeviceGroup") +Location = load_model("geo", "Location") +DeviceLocation = load_model("geo", "DeviceLocation") +BatchCommand = load_model("connection", "BatchCommand") class TestConnectionAdmin(TestAdminMixin, CreateConnectionsMixin, TestCase): @@ -154,6 +174,34 @@ def test_command_inline(self): response = self.client.get(url) self.assertContains(response, "Recent Commands") + def test_command_inline_batch_command(self): + url = reverse( + f"admin:{self.config_app_label}_device_change", args=(self.device.id,) + ) + batch = self._create_batch_command( + self.device.organization, label="nightly reboot" + ) + batch_url = reverse( + f"admin:{BatchCommand._meta.app_label}_batchcommand_change", + args=(batch.pk,), + ) + with self.subTest("a command of a mass command links to it"): + Command.objects.create( + type="custom", + input={"command": "echo hello"}, + device=self.device, + batch_command=batch, + ) + response = self.client.get(url) + self.assertContains( + response, f'nightly reboot', html=True + ) + Command.objects.all().delete() + with self.subTest("a command sent to the device alone has no link"): + self._create_custom_command() + response = self.client.get(url) + self.assertNotContains(response, batch_url) + def test_command_inline_output_loading_overlay(self): url = reverse( f"admin:{self.config_app_label}_device_change", args=(self.device.id,) @@ -280,3 +328,1085 @@ def test_notification_host_setting(self, ctx_processors=[]): response = self.client.get(url) self.assertContains(response, "https://example.com") self.assertNotContains(response, "owControllerApiHost = window.location") + + +class TestBatchCommandAdmin(BatchCommandMixin, TestCase): + app_label = "connection" + + def setUp(self): + self._create_admin() + self.execute_url = reverse(f"admin:{self.app_label}_batchcommand_execute") + self.confirm_url = reverse(f"admin:{self.app_label}_batchcommand_confirm") + self.changelist_url = reverse(f"admin:{self.app_label}_batchcommand_changelist") + + def test_wizard_permissions_and_tenant_isolation(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + self._create_device(organization=org) + device2 = self._create_device( + name="device2", mac_address="00:11:22:33:44:02", organization=org2 + ) + + with self.subTest("view permission is not enough"): + viewer = self._create_operator( + organizations=[org], username="viewer", email="viewer@test.com" + ) + viewer.groups.clear() + viewer.user_permissions.set( + Permission.objects.filter(codename="view_batchcommand") + ) + self.client.force_login(viewer) + self.assertEqual(self.client.get(self.execute_url).status_code, 403) + self.assertEqual(self.client.get(self.confirm_url).status_code, 403) + + with self.subTest("the operator group can reach the wizard"): + operator = self._create_operator(organizations=[org]) + self.client.force_login(operator) + self.assertFalse( + BatchCommandAdmin(BatchCommand, admin.site).has_add_permission(None) + ) + self.assertEqual(self.client.get(self.execute_url).status_code, 200) + + with self.subTest("a target is required for non superusers"): + response = self._post_execute() + self.assertContains(response, "Please select at least one of") + + with self.subTest("devices of unmanaged organizations are not reachable"): + wizard = self._start_wizard(organization=str(org.pk)) + wizard["organization_id"] = str(org2.pk) + session = self.client.session + session[BatchCommandAdmin.session_key] = wizard + session.save() + self.client.get(self.confirm_url) + response = self._post_confirm(wizard["token"]) + self.assertIn( + "No devices match the specified criteria.", self._messages(response) + ) + self.assertFalse(BatchCommand.objects.exists()) + + with self.subTest("superusers may target every device"): + self._login() + self._start_wizard() + response = self.client.get(self.confirm_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["device_count"], 2) + self.assertIn(device2, response.context["cl"].queryset) + + def test_wizard_incompatible_scopes(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + group2 = DeviceGroup.objects.create(name="group2", organization=org2) + location2 = Location.objects.create( + name="location2", type="indoor", organization=org2 + ) + group = DeviceGroup.objects.create(name="group1", organization=org) + location = Location.objects.create( + name="location1", type="indoor", organization=org + ) + device = self._create_device(organization=org, group=group) + operator = self._create_operator(organizations=[org, org2]) + self.client.force_login(operator) + + with self.subTest("group of another organization"): + response = self._post_execute( + organization=str(org.pk), group=str(group2.pk) + ) + self.assertIn("group", response.context["form"].errors) + + with self.subTest("location of another organization"): + response = self._post_execute( + organization=str(org.pk), location=str(location2.pk) + ) + self.assertIn("location", response.context["form"].errors) + + with self.subTest("the organization is derived from the group"): + wizard = self._start_wizard(group=str(group.pk)) + self.assertEqual(wizard["group_id"], str(group.pk)) + response = self.client.get(self.confirm_url) + self.assertEqual(response.context["device_count"], 1) + + with self.subTest("scopes which share no devices"): + wizard = self._start_wizard( + organization=str(org.pk), + group=str(group.pk), + location=str(location.pk), + ) + response = self.client.get(self.confirm_url) + self.assertEqual(response.context["device_count"], 0) + response = self._post_confirm(wizard["token"]) + self.assertIn( + "No devices match the specified criteria.", self._messages(response) + ) + self.assertFalse(BatchCommand.objects.exists()) + self.assertIn(device, Device.objects.all()) + + def test_wizard_form_fields(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + group = DeviceGroup.objects.create(name="group1", organization=org) + group2 = DeviceGroup.objects.create(name="group2", organization=org2) + location = Location.objects.create( + name="location1", type="indoor", organization=org + ) + location2 = Location.objects.create( + name="location2", type="indoor", organization=org2 + ) + operator = self._create_operator(organizations=[org]) + self.client.force_login(operator) + + with self.subTest("choices are limited to the managed organizations"): + form = self.client.get(self.execute_url).context["form"] + self.assertEqual(list(form.fields["organization"].queryset), [org]) + self.assertEqual(list(form.fields["group"].queryset), [group]) + self.assertEqual(list(form.fields["location"].queryset), [location]) + + with self.subTest("types are limited to the enabled commands"): + with patch.dict(ORGANIZATION_ENABLED_COMMANDS, {str(org.pk): ("reboot",)}): + form = self.client.get(self.execute_url).context["form"] + choices = form.fields["type"].choices + self.assertEqual([value for value, _ in choices], ["", "reboot"]) + + with self.subTest("superusers are not restricted"): + self._login() + form = self.client.get(self.execute_url).context["form"] + self.assertIn(group2, form.fields["group"].queryset) + self.assertIn(location2, form.fields["location"].queryset) + self.assertIn(org2, form.fields["organization"].queryset) + + def test_wizard_schema_view(self): + org = self._get_org() + url = reverse(f"admin:{self.app_label}_batchcommand_schema") + + with self.subTest("superusers get every enabled type"): + self._login() + schemas = self.client.get(url).json() + form = self.client.get(self.execute_url).context["form"] + choices = {value for value, _ in form.fields["type"].choices if value} + self.assertEqual(set(schemas), choices) + + with self.subTest("superusers get the schemas of every organization"): + with patch.dict( + ORGANIZATION_COMMAND_SCHEMA, + { + "__all__": {"reboot": COMMANDS["reboot"]["schema"]}, + str(org.pk): { + "change_password": COMMANDS["change_password"]["schema"] + }, + }, + clear=True, + ): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(sorted(response.json()), ["change_password", "reboot"]) + + with self.subTest("superusers do not need an __all__ configuration"): + with patch.dict( + ORGANIZATION_COMMAND_SCHEMA, + {str(org.pk): {"reboot": COMMANDS["reboot"]["schema"]}}, + clear=True, + ): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(list(response.json()), ["reboot"]) + + with self.subTest("an empty configuration returns an empty object"): + with patch.dict(ORGANIZATION_COMMAND_SCHEMA, {}, clear=True): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {}) + + with self.subTest("managers get the union of their organizations"): + operator = self._create_operator(organizations=[org]) + self.client.force_login(operator) + with patch.dict( + ORGANIZATION_COMMAND_SCHEMA, + {str(org.pk): {"reboot": COMMANDS["reboot"]["schema"]}}, + ): + self.assertEqual(list(self.client.get(url).json()), ["reboot"]) + + with self.subTest("the add permission is required"): + viewer = self._create_operator( + organizations=[org], username="viewer", email="viewer@test.com" + ) + viewer.groups.clear() + viewer.user_permissions.set( + Permission.objects.filter(codename="view_batchcommand") + ) + self.client.force_login(viewer) + self.assertEqual(self.client.get(url).status_code, 403) + + def test_wizard_views_reject_unsupported_methods(self): + self._login() + response = self.client.delete(self.execute_url) + self.assertEqual(response.status_code, 405) + self.assertEqual(response["Allow"], "GET, POST") + response = self.client.delete(self.confirm_url) + self.assertEqual(response.status_code, 405) + self.assertEqual(response["Allow"], "GET, POST") + + def test_wizard_back_restores_the_form(self): + org = self._get_org() + group = DeviceGroup.objects.create(name="back-group", organization=org) + self._create_device(organization=org, group=group) + self._login() + self._post_execute( + type="custom", + input='{"command": "echo back"}', + label="back-label", + notes="back notes", + organization=str(org.pk), + group=str(group.pk), + ) + form = self.client.get(f"{self.execute_url}?back=1").context["form"] + self.assertEqual(form.initial["type"], "custom") + self.assertEqual(form.initial["input"], {"command": "echo back"}) + self.assertEqual(form.initial["label"], "back-label") + self.assertEqual(form.initial["notes"], "back notes") + self.assertEqual(form.initial["organization"], str(org.pk)) + self.assertEqual(form.initial["group"], str(group.pk)) + self.assertIsNone(form.initial["location"]) + self.assertIn(BatchCommandAdmin.session_key, self.client.session) + form = self.client.get(self.execute_url).context["form"] + self.assertEqual(form.initial, {}) + self.assertIn(BatchCommandAdmin.session_key, self.client.session) + + def test_wizard_device_admin_composition(self): + class ReplacementDeviceAdmin(DeviceAdmin): + change_list_template = "admin/connection/replacement_change_list.html" + readonly_fields = ["last_ip"] + + def monitoring_status(self, obj): + return "ok" + + monitoring_status.short_description = "monitoring status" + + model_admin = BatchCommandAdmin(BatchCommand, admin.site) + device_admin_class = type(admin.site.get_model_admin(Device)) + admin.site.unregister(Device) + admin.site.register(Device, ReplacementDeviceAdmin) + try: + registered_readonly = list(ReplacementDeviceAdmin.readonly_fields) + device_admin = model_admin.get_device_admin(Device.objects.none()) + model_admin.get_device_admin(Device.objects.none()) + template = model_admin.get_device_changelist_template() + finally: + admin.site.unregister(Device) + admin.site.register(Device, device_admin_class) + + self.assertIsInstance(device_admin, ReplacementDeviceAdmin) + self.assertTrue(hasattr(device_admin, "monitoring_status")) + self.assertEqual(template, "admin/connection/replacement_change_list.html") + self.assertIn("last_ip", device_admin.readonly_fields) + self.assertEqual( + list(ReplacementDeviceAdmin.readonly_fields), registered_readonly + ) + + with self.subTest("a registration without a template falls back"): + + class BareDeviceAdmin(DeviceAdmin): + change_list_template = None + + admin.site.unregister(Device) + admin.site.register(Device, BareDeviceAdmin) + try: + self.assertEqual( + model_admin.get_device_changelist_template(), + "admin/change_list.html", + ) + finally: + admin.site.unregister(Device) + admin.site.register(Device, device_admin_class) + + self.assertIs(type(admin.site.get_model_admin(Device)), device_admin_class) + + def test_wizard_and_detail_query_budget(self): + """The pages must not run a query per listed row. + + The absolute count depends on caches which are warm or cold depending + on what ran before, so the budget asserted here is that listing three + times as many rows costs exactly the same number of queries. + """ + org = self._get_org() + devices = [ + self._create_device( + name=f"budget{index}", + mac_address=f"00:11:22:33:55:{index:02x}", + organization=org, + ) + for index in range(4) + ] + batch = self._create_batch_command(organization=org) + self._create_commands(batch, devices) + batch.skipped_devices = { + str(uuid4()): {"name": "skipped-device", "error": "no credentials"} + } + batch.save(update_fields=["skipped_devices"]) + self._login() + + with self.subTest("the confirm page"): + self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + with CaptureQueriesContext(db_connection) as few: + self.client.get(self.confirm_url) + more_devices = [ + self._create_device( + name=f"budget-more{index}", + mac_address=f"00:11:22:33:56:{index:02x}", + organization=org, + ) + for index in range(8) + ] + self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + with CaptureQueriesContext(db_connection) as many: + response = self.client.get(self.confirm_url) + self.assertEqual(response.context["device_count"], len(devices) + 8) + self.assertEqual(len(many.captured_queries), len(few.captured_queries)) + + with self.subTest("the detail page"): + url = reverse( + f"admin:{self.app_label}_batchcommand_change", args=[batch.pk] + ) + self.client.get(url) + with CaptureQueriesContext(db_connection) as few: + self.client.get(url) + self._create_commands(batch, more_devices) + self.client.get(url) + with CaptureQueriesContext(db_connection) as many: + response = self.client.get(url) + self.assertEqual(len(response.context["commands"]), len(devices) + 8 + 1) + self.assertEqual(len(many.captured_queries), len(few.captured_queries)) + + def test_wizard_review_step_input(self): + model_admin = BatchCommandAdmin(BatchCommand, admin.site) + cases = ( + (None, ""), + ("not-a-mapping", ""), + ({"command": "uptime"}, "uptime"), + ({"config": "network"}, "config: network"), + ( + {"service": "firewall", "action": "restart"}, + "service: firewall, action: restart", + ), + ({"password": "tester123", "confirm_password": "tester123"}, ""), + ({"newPassword": "tester123"}, ""), + ({"Password": "tester123", "host": "10.0.0.1"}, "host: 10.0.0.1"), + ) + for command_input, expected in cases: + with self.subTest(str(command_input)): + self.assertEqual(model_admin._describe_input(command_input), expected) + + def test_wizard_recovers_from_unresolvable_targets(self): + org = self._get_org() + self._create_device(organization=org) + self._login() + + with self.subTest("a confirm page without a wizard restarts"): + response = self.client.get(self.confirm_url) + self.assertRedirects(response, self.execute_url) + + with self.subTest("targets which cannot be resolved list no devices"): + self._start_wizard(organization=str(org.pk)) + with patch.object( + BatchCommand, "dry_run", side_effect=ValidationError("broken") + ): + with self.assertLogs( + "openwisp_controller.connection.admin", level="WARNING" + ) as logs: + response = self.client.get(self.confirm_url) + self.assertEqual(response.context["device_count"], 0) + self.assertIn( + "Failed to resolve devices for mass command wizard", logs.output[0] + ) + + with self.subTest("a batch which disappears restarts"): + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + with patch.object(BatchCommand, "execute", side_effect=Device.DoesNotExist): + with self.assertLogs( + "openwisp_controller.connection.admin", level="WARNING" + ) as logs: + response = self._post_confirm(wizard["token"]) + self.assertRedirects(response, self.execute_url) + self.assertFalse(BatchCommand.objects.exists()) + self.assertIn("Failed to execute mass command wizard", logs.output[0]) + self.assertIn(str(org.pk), logs.output[0]) + + def test_wizard_stale_and_parallel_sessions(self): + org = self._get_org() + self._create_device(organization=org) + self._login() + restart_message = "Please fill in the mass command details to continue." + + with self.subTest("no wizard in the session"): + response = self._post_confirm("any-token") + self.assertRedirects(response, self.execute_url) + self.assertIn(restart_message, self._messages(response)) + + with self.subTest("a token from another tab"): + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + response = self._post_confirm("stale-token") + self.assertRedirects(response, self.execute_url) + self.assertIn( + "This mass command was replaced by another one started in a" + " different browser tab, so it was not executed. Please fill" + " in the details again.", + self._messages(response), + ) + self.assertFalse(BatchCommand.objects.exists()) + + with self.subTest("double submit"): + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + self._post_confirm(wizard["token"]) + batch = BatchCommand.objects.get() + response = self._post_confirm(wizard["token"]) + self.assertRedirects(response, self.execute_url) + self.assertIn(restart_message, self._messages(response)) + self.assertEqual(list(BatchCommand.objects.all()), [batch]) + BatchCommand.objects.all().delete() + + with self.subTest("the targeted devices changed"): + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + self._create_device( + name="late-device", + mac_address="00:11:22:33:44:99", + organization=org, + ) + response = self._post_confirm(wizard["token"]) + self.assertRedirects(response, self.confirm_url) + self.assertIn( + "The targeted devices changed, please review them again.", + self._messages(response), + ) + self.assertFalse(BatchCommand.objects.exists()) + self.assertIn(BatchCommandAdmin.session_key, self.client.session) + + def test_wizard_survives_a_second_tab(self): + org = self._get_org() + self._create_device(organization=org) + self._login() + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + # another tab opens the first step, which must not discard the wizard + self.client.get(self.execute_url) + self.assertIn(BatchCommandAdmin.session_key, self.client.session) + response = self._post_confirm(wizard["token"]) + batch = BatchCommand.objects.get() + self.assertRedirects( + response, + reverse(f"admin:{self.app_label}_batchcommand_change", args=(batch.pk,)), + ) + + def test_wizard_overlapping_execute_requests(self): + org = self._get_org() + self._create_device(organization=org) + self._login() + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + model_admin = BatchCommandAdmin(BatchCommand, admin.site) + session_key = self.client.session.session_key + + def build_request(): + request = RequestFactory().post( + self.confirm_url, {"token": wizard["token"], "excluded": ""} + ) + request.user = self._get_admin() + request.session = SessionStore(session_key=session_key) + # reading the wizard caches the session in the request, which is + # what makes both requests see it before either of them responds + assert request.session.get(BatchCommandAdmin.session_key) + setattr(request, "_messages", FallbackStorage(request)) + return request + + first, second = build_request(), build_request() + model_admin._execute_batch_command(first) + first.session.save() + model_admin._execute_batch_command(second) + second.session.save() + self.assertEqual(BatchCommand.objects.count(), 1) + + def test_wizard_device_selection(self): + org = self._get_org() + devices = [self._create_device(organization=org)] + devices += [ + self._create_device( + name=f"device{index}", + mac_address=f"00:11:22:33:44:0{index}", + organization=org, + ) + for index in range(1, 4) + ] + self._login() + + with self.subTest("excluded devices are left out"): + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + self._post_confirm(wizard["token"], excluded=str(devices[-1].pk)) + batch = BatchCommand.objects.get() + self.assertEqual(set(batch.devices.all()), set(devices[:-1])) + BatchCommand.objects.all().delete() + + with self.subTest("malformed entries are ignored"): + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + self._post_confirm( + wizard["token"], + excluded=f",not-a-uuid,,{uuid4()},{devices[0].pk},", + ) + batch = BatchCommand.objects.get() + self.assertEqual(set(batch.devices.all()), set(devices[1:])) + BatchCommand.objects.all().delete() + + with self.subTest("excluding every device"): + wizard = self._start_wizard(organization=str(org.pk)) + self.client.get(self.confirm_url) + response = self._post_confirm( + wizard["token"], + excluded=",".join(str(device.pk) for device in devices), + ) + self.assertRedirects(response, self.confirm_url) + self.assertIn( + "No devices match the specified criteria.", self._messages(response) + ) + self.assertFalse(BatchCommand.objects.exists()) + self.assertIn(BatchCommandAdmin.session_key, self.client.session) + + def test_changelist_multitenancy(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + batch = self._create_batch_command(organization=org) + batch2 = self._create_batch_command(organization=org2, label="other-label") + operator = self._create_operator(organizations=[org]) + self.client.force_login(operator) + + with self.subTest("only managed organizations are listed"): + response = self.client.get(self.changelist_url) + queryset = response.context["cl"].queryset + self.assertIn(batch, queryset) + self.assertNotIn(batch2, queryset) + + with self.subTest("an unmanaged batch cannot be opened"): + url = reverse( + f"admin:{self.app_label}_batchcommand_change", args=[batch2.pk] + ) + self.assertEqual(self.client.get(url).status_code, 302) + + with self.subTest("superusers see every batch"): + self._login() + response = self.client.get(self.changelist_url) + queryset = response.context["cl"].queryset + self.assertIn(batch, queryset) + self.assertIn(batch2, queryset) + + def test_changelist_filters_and_search(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + group = DeviceGroup.objects.create(name="group1", organization=org) + device = self._create_device(organization=org, group=group) + batch = self._create_batch_command( + organization=org, group=group, devices=[device] + ) + batch2 = self._create_batch_command( + organization=org2, + label="other-label", + type="reboot", + input=None, + status="success", + ) + self._login() + cases = ( + ("status", {"status": "success"}, batch2), + ("type", {"type": "custom"}, batch), + ("group", {"group_id": str(group.pk)}, batch), + ("search by label", {"q": "other-label"}, batch2), + ("search by device name", {"q": device.name}, batch), + ("search by group name", {"q": "group1"}, batch), + ) + for name, params, expected in cases: + with self.subTest(name): + response = self.client.get(self.changelist_url, params) + queryset = response.context["cl"].queryset + self.assertEqual(list(queryset), [expected]) + + with self.subTest("combined filters exclude everything"): + response = self.client.get( + self.changelist_url, {"status": "success", "type": "custom"} + ) + self.assertEqual(response.context["cl"].queryset.count(), 0) + + with self.subTest("affected devices is orderable and not duplicated"): + response = self.client.get(self.changelist_url, {"o": "5"}) + queryset = response.context["cl"].queryset + self.assertEqual(set(queryset), {batch, batch2}) + self.assertEqual([item._affected_devices for item in queryset], [0, 0]) + + def test_changelist_is_read_only(self): + batch = self._create_batch_command(organization=self._get_org()) + self._login() + model_admin = BatchCommandAdmin(BatchCommand, admin.site) + response = self.client.get(self.changelist_url) + + with self.subTest("no bulk actions"): + self.assertNotIn( + "delete_selected", model_admin.get_actions(response.wsgi_request) + ) + + with self.subTest("adding and deleting are disabled"): + self.assertFalse(model_admin.has_add_permission(response.wsgi_request)) + self.assertFalse( + model_admin.has_delete_permission(response.wsgi_request, batch) + ) + + with self.subTest("no save buttons on the change page"): + url = reverse( + f"admin:{self.app_label}_batchcommand_change", args=[batch.pk] + ) + response = self.client.get(url) + self.assertFalse(response.context["show_save"]) + self.assertFalse(response.context["show_save_and_continue"]) + + def _create_commands(self, batch, devices, **kwargs): + commands = [] + for device in devices: + if not hasattr(device, "config"): + self._create_config(device=device) + connection = self._create_device_connection( + device=device, + credentials=self._create_credentials( + name=f"cred-{device.name}", organization=device.organization + ), + ) + with patch.object(Command, "_schedule_command"): + commands.append( + Command.objects.create( + batch_command=batch, + device=device, + connection=connection, + type="custom", + input={"command": "echo test"}, + **kwargs, + ) + ) + return commands + + def test_change_view_command_rows(self): + org = self._get_org() + batch = self._create_batch_command(organization=org) + devices = [self._create_device(organization=org)] + [ + self._create_device( + name=f"device{index}", + mac_address=f"00:11:22:33:44:0{index}", + organization=org, + ) + for index in range(1, 4) + ] + commands = self._create_commands(batch, devices, output="first\nlast") + batch.skipped_devices = { + str(uuid4()): {"name": "skipped-device", "error": "no credentials"} + } + batch.skipped_devices.update( + { + str(uuid4()): {"name": f"skipped{index}", "error": "no credentials"} + for index in range(4) + } + ) + skipped_names = [skipped["name"] for skipped in batch.skipped_devices.values()] + batch.save(update_fields=["skipped_devices"]) + self._login() + url = reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + with patch.object(BatchCommandAdmin, "device_commands_per_page", 3): + with self.subTest("first page"): + rows = self.client.get(url).context["commands"] + self.assertEqual( + [row["device_name"] for row in rows], + [command.device.name for command in commands[:3]], + ) + self.assertEqual(rows[0]["output"], "… last") + self.assertEqual(rows[0]["status_display"], "in progress") + self.assertFalse(rows[0]["is_skipped"]) + self.assertEqual( + rows[0]["modified_display"], + format_localized_datetime(commands[0].modified), + ) + + with self.subTest("the rows follow the locale and the time zone"): + default = self.client.get(url).context["commands"][0] + with override_settings( + LANGUAGE_CODE="it", TIME_ZONE="Pacific/Auckland" + ): + localized = self.client.get(url).context["commands"][0] + self.assertEqual( + localized["modified_display"], + format_localized_datetime(commands[0].modified), + ) + self.assertNotEqual( + localized["modified_display"], default["modified_display"] + ) + + with self.subTest("skipped devices have no timestamp"): + rows = self.client.get(url, {"page": 3}).context["commands"] + self.assertEqual([row["modified_display"] for row in rows], [""] * 3) + + with self.subTest("the page spanning commands and skipped devices"): + rows = self.client.get(url, {"page": 2}).context["commands"] + self.assertEqual( + [row["is_skipped"] for row in rows], [False, True, True] + ) + self.assertEqual( + [row["device_name"] for row in rows[1:]], skipped_names[:2] + ) + + with self.subTest("a page made of skipped devices only"): + rows = self.client.get(url, {"page": 3}).context["commands"] + self.assertEqual([row["is_skipped"] for row in rows], [True] * 3) + self.assertEqual( + [row["device_name"] for row in rows], skipped_names[2:] + ) + + with self.subTest("the unfiltered page does not copy the skipped devices"): + filters = { + "q": "", + "status": "", + "location_id": "", + "group_id": "", + "organization_id": "", + } + self.assertEqual( + batch.filter_skipped_items(filters), + batch.skipped_devices.items(), + ) + filters["q"] = "skipped0" + self.assertEqual( + [ + skipped["name"] + for _pk, skipped in batch.filter_skipped_items(filters) + ], + ["skipped0"], + ) + + with self.subTest("an unusable page falls back to the first"): + for page in ("abc", 0, 99): + response = self.client.get(url, {"page": page}) + self.assertEqual(response.context["page_obj"].number, 1) + + with self.subTest("the newest command is last"): + rows = self.client.get(url, {"page": 2}).context["commands"] + self.assertEqual(rows[0]["device"], commands[-1].device.pk) + + def test_change_view_filters(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + group = DeviceGroup.objects.create(name="group1", organization=org) + location = Location.objects.create( + name="location1", type="indoor", organization=org + ) + device = self._create_device(organization=org, group=group) + DeviceLocation.objects.create(content_object=device, location=location) + batch = self._create_batch_command(organization=org) + other_group = DeviceGroup.objects.create(name="skipped-group", organization=org) + skipped_device = self._create_device( + name="skipped-device", + mac_address="00:11:22:33:44:88", + organization=org, + group=other_group, + ) + self._create_commands(batch, [device], status="success") + transferred_group = DeviceGroup.objects.create( + name="transferred-group", organization=org2 + ) + transferred_location = Location.objects.create( + name="transferred-location", type="indoor", organization=org2 + ) + transferred_device = self._create_device( + name="transferred-device", + mac_address="00:11:22:33:44:89", + organization=org2, + group=transferred_group, + ) + DeviceLocation.objects.create( + content_object=transferred_device, location=transferred_location + ) + batch.skipped_devices = { + str(skipped_device.pk): { + "name": skipped_device.name, + "error": "no credentials", + }, + str(transferred_device.pk): { + "name": transferred_device.name, + "error": "moved out of the organization of the mass command", + }, + } + batch.save(update_fields=["skipped_devices"]) + self._login() + url = reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + + with self.subTest("search matches commands and skipped devices"): + rows = self.client.get(url, {"q": device.name}).context["commands"] + self.assertEqual([row["device_name"] for row in rows], [device.name]) + rows = self.client.get(url, {"q": "skipped"}).context["commands"] + self.assertEqual([row["device_name"] for row in rows], ["skipped-device"]) + + with self.subTest("status skipped hides the commands"): + rows = self.client.get(url, {"status": "skipped"}).context["commands"] + self.assertEqual([row["is_skipped"] for row in rows], [True, True]) + + with self.subTest("status success hides the skipped devices"): + rows = self.client.get(url, {"status": "success"}).context["commands"] + self.assertEqual([row["is_skipped"] for row in rows], [False]) + + with self.subTest("the all choice clears the only active filter"): + specs = self.client.get(url, {"status": "success"}).context["filter_specs"] + self.assertEqual(specs[0].choices[0]["display"], "All") + self.assertEqual(specs[0].choices[0]["query_string"], "?") + + with self.subTest("groups of skipped devices are offered as filters"): + response = self.client.get(url) + titles = { + str(spec.title): spec for spec in response.context["filter_specs"] + } + displays = [ + str(choice["display"]) for choice in titles["device group"].choices + ] + self.assertIn(other_group.name, displays) + self.assertIn(group.name, displays) + + with self.subTest("filtering by a group keeps only its devices"): + rows = self.client.get(url, {"group_id": str(other_group.pk)}).context[ + "commands" + ] + self.assertEqual([row["device_name"] for row in rows], ["skipped-device"]) + + with self.subTest("filtering by location"): + DeviceLocation.objects.create( + content_object=skipped_device, location=location + ) + rows = self.client.get(url, {"location_id": str(location.pk)}).context[ + "commands" + ] + self.assertEqual( + sorted(row["device_name"] for row in rows), + sorted([device.name, "skipped-device"]), + ) + + with self.subTest("filtering by organization"): + rows = self.client.get(url, {"organization_id": str(org.pk)}).context[ + "commands" + ] + self.assertEqual( + sorted(row["device_name"] for row in rows), + sorted([device.name, "skipped-device"]), + ) + rows = self.client.get(url, {"organization_id": str(org2.pk)}).context[ + "commands" + ] + self.assertEqual( + [row["device_name"] for row in rows], [transferred_device.name] + ) + + with self.subTest("the organization filter is for superusers only"): + operator = self._create_operator(organizations=[org]) + self.client.force_login(operator) + response = self.client.get(url) + titles = [str(spec.title) for spec in response.context["filter_specs"]] + self.assertNotIn("organization", titles) + + def _filter_specs(target): + return { + str(spec.title): [str(choice["display"]) for choice in spec.choices] + for spec in self.client.get(target).context["filter_specs"] + } + + with self.subTest("the filters do not offer other organizations"): + for login in (lambda: self.client.force_login(operator), self._login): + login() + specs = _filter_specs(url) + self.assertNotIn(transferred_group.name, specs["device group"]) + self.assertIn(other_group.name, specs["device group"]) + self.assertNotIn(transferred_location.name, specs["location"]) + self.assertIn(location.name, specs["location"]) + + with self.subTest("the filters do not wait for the commands to be created"): + fresh = self._create_batch_command(organization=org) + specs = _filter_specs( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[fresh.pk]) + ) + self.assertIn(group.name, specs["device group"]) + self.assertIn(location.name, specs["location"]) + self.assertNotIn("organization", specs) + + with self.subTest("the target of the batch is not offered as a filter"): + targeted = self._create_batch_command(organization=org, group=group) + specs = _filter_specs( + reverse( + f"admin:{self.app_label}_batchcommand_change", args=[targeted.pk] + ) + ) + self.assertNotIn("device group", specs) + self.assertIn("location", specs) + targeted = self._create_batch_command(organization=org, location=location) + specs = _filter_specs( + reverse( + f"admin:{self.app_label}_batchcommand_change", args=[targeted.pk] + ) + ) + self.assertNotIn("location", specs) + self.assertIn("device group", specs) + + with self.subTest("a system wide batch offers every organization"): + system_wide = self._create_batch_command(organization=None) + specs = _filter_specs( + reverse( + f"admin:{self.app_label}_batchcommand_change", + args=[system_wide.pk], + ) + ) + self.assertIn(org.name, specs["organization"]) + self.assertIn(org2.name, specs["organization"]) + self.assertIn(transferred_group.name, specs["device group"]) + self.assertIn(transferred_location.name, specs["location"]) + + with self.subTest("the search term is not an active filter"): + response = self.client.get(url, {"q": "anything"}) + self.assertFalse(response.context["has_active_filters"]) + response = self.client.get(url, {"status": "success"}) + self.assertTrue(response.context["has_active_filters"]) + + def test_change_view_display_fields(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + batch = self._create_batch_command(organization=org) + model_admin = BatchCommandAdmin(BatchCommand, admin.site) + + with self.subTest("organization display"): + self.assertEqual(model_admin.organization_display(batch), org.name) + shared = self._create_batch_command(organization=None, label="shared") + self.assertEqual(str(model_admin.organization_display(shared)), "All") + + with self.subTest("colored status"): + self.assertIn( + f"command-status {batch.status}", model_admin.colored_status(batch) + ) + + with self.subTest("formatted input"): + self.assertEqual(model_admin.formatted_input(batch), "echo test") + empty_batch = self._create_batch_command( + organization=org, label="empty", type="reboot", input=None + ) + self.assertEqual(model_admin.formatted_input(empty_batch), "-") + registered_batch = self._create_batch_command( + organization=org, label="registered" + ) + registered_batch.input = {"config": "network"} + self.assertEqual( + model_admin.formatted_input(registered_batch), "config: network" + ) + password_batch = self._create_batch_command( + organization=org, + label="password", + type="change_password", + input={"password": "tester123", "confirm_password": "tester123"}, + ) + self.assertEqual(model_admin.formatted_input(password_batch), "********") + + with self.subTest("affected devices falls back to the model"): + self.assertEqual(model_admin.affected_devices(batch), 0) + batch._affected_devices = 7 + self.assertEqual(model_admin.affected_devices(batch), 7) + + with self.subTest("skipped devices rendering"): + self.assertEqual(model_admin.display_skipped_devices(batch), "-") + batch.skipped_devices = { + str(uuid4()): {"name": f"device{index}", "error": "failed"} + for index in range(12) + } + rendered = model_admin.display_skipped_devices(batch) + self.assertIn("12", rendered) + self.assertIn("device0: failed", rendered) + self.assertIn("…", rendered) + + with self.subTest("commands of unmanaged organizations are hidden"): + device2 = self._create_device( + name="device-org2", + mac_address="00:11:22:33:44:77", + organization=org2, + ) + self._create_commands(batch, [device2]) + operator = self._create_operator(organizations=[org]) + request = self.client.get(self.changelist_url).wsgi_request + request.user = operator + self.assertEqual(model_admin._get_commands(request, batch).count(), 0) + + def test_changelist_filter_classes(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + group = DeviceGroup.objects.create(name="group1", organization=org) + location = Location.objects.create( + name="location1", type="indoor", organization=org + ) + group2 = DeviceGroup.objects.create(name="group2", organization=org2) + location2 = Location.objects.create( + name="location2", type="indoor", organization=org2 + ) + batch = self._create_batch_command(organization=org, group=group) + batch2 = self._create_batch_command( + organization=org2, label="other-label", type="reboot", input=None + ) + model_admin = BatchCommandAdmin(BatchCommand, admin.site) + operator = self._create_operator(organizations=[org]) + self.client.force_login(operator) + request = self.client.get(self.changelist_url).wsgi_request + + with self.subTest("type lookups are limited to the managed organizations"): + type_filter = TypeFilter(request, {}, BatchCommand, model_admin) + self.assertEqual( + type_filter.lookups(request, model_admin), + list(Command.get_org_allowed_commands(organization_id=org.pk)), + ) + + with self.subTest("type lookups list every type for superusers"): + self._login() + admin_request = self.client.get(self.changelist_url).wsgi_request + type_filter = TypeFilter(admin_request, {}, BatchCommand, model_admin) + self.assertEqual( + type_filter.lookups(admin_request, model_admin), + list(get_command_choices()), + ) + + with self.subTest("type queryset"): + response = self.client.get(self.changelist_url, {"type": "reboot"}) + self.assertEqual(list(response.context["cl"].queryset), [batch2]) + response = self.client.get(self.changelist_url) + self.assertEqual(set(response.context["cl"].queryset), {batch, batch2}) + + with self.subTest("the parameter names match the change page filters"): + self.assertEqual(GroupFilter.parameter_name, "group_id") + self.assertEqual(LocationFilter.parameter_name, "location_id") + + with self.subTest("the filters are on the changelist"): + self.client.force_login(operator) + response = self.client.get(self.changelist_url) + specs = {type(spec) for spec in response.context["cl"].filter_specs} + self.assertIn(GroupFilter, specs) + self.assertIn(LocationFilter, specs) + + with self.subTest("related choices are limited to the managed organizations"): + self._create_administrator(organizations=[org]) + self._test_multitenant_admin( + url=self._get_autocomplete_view_path( + self.app_label, "batchcommand", "group" + ), + visible=[group.name], + hidden=[group2.name], + administrator=True, + ) + self._test_multitenant_admin( + url=self._get_autocomplete_view_path( + self.app_label, "batchcommand", "location" + ), + visible=[location.name], + hidden=[location2.name], + administrator=True, + ) diff --git a/openwisp_controller/connection/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py index ef46278f2..81a08b47f 100644 --- a/openwisp_controller/connection/tests/test_api.py +++ b/openwisp_controller/connection/tests/test_api.py @@ -1065,7 +1065,7 @@ def test_batch_command_endpoints_no_of_queries(self): "devices": [str(d.pk) for d in devices], } url = reverse("connection_api:batch_command_execute") - with self.assertNumQueries(16): + with self.assertNumQueries(15): response = self.client.post( url, data=json.dumps(payload), @@ -1102,7 +1102,7 @@ def test_batch_command_endpoints_no_of_queries(self): "group": str(group.pk), } url = reverse("connection_api:batch_command_execute") - with self.assertNumQueries(15): + with self.assertNumQueries(14): response = self.client.post( url, data=json.dumps(payload), @@ -1128,7 +1128,7 @@ def test_batch_command_endpoints_no_of_queries(self): "label": "test-label", } url = reverse("connection_api:batch_command_execute") - with self.assertNumQueries(13): + with self.assertNumQueries(12): response = self.client.post( url, data=json.dumps(payload), @@ -1176,7 +1176,7 @@ def test_batch_command_execute_disallowed_type(self): ) self.assertEqual(response.status_code, 400) self.assertIn( - '"custom" command is not available for this organization', + '"custom" command is not available for the target organization(s)', response.data["type"][0], ) @@ -1254,14 +1254,33 @@ def test_superuser_batch_command_execute_without_org(self): "type": "custom", "input": {"command": "echo test"}, "label": "test-label", - "devices": [str(device1.pk), str(device2.pk)], + "devices": [str(device1.pk)], } ), content_type="application/json", ) self.assertEqual(response.status_code, 201) batch = BatchCommand.objects.get(pk=response.data["batch"]) - self.assertEqual(batch.devices.count(), 2) + self.assertEqual(batch.devices.count(), 1) + + with self.subTest("execute with devices of different organizations"): + response = self.client.post( + url, + data=json.dumps( + { + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device1.pk), str(device2.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + "must belong to the same organization", + response.data["devices"][0], + ) with self.subTest("dry-run targets all devices"): response = self.client.get(url) @@ -1270,12 +1289,19 @@ def test_superuser_batch_command_execute_without_org(self): self.assertIn(str(device2.pk), response.data["devices"]) with self.subTest("dry-run with explicit devices"): + response = self.client.get(f"{url}?devices={str(device1.pk)}") + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + + with self.subTest("dry-run with devices of different organizations"): response = self.client.get( f"{url}?devices={str(device1.pk)}&devices={str(device2.pk)}" ) - self.assertEqual(response.status_code, 200) - self.assertIn(str(device1.pk), response.data["devices"]) - self.assertIn(str(device2.pk), response.data["devices"]) + self.assertEqual(response.status_code, 400) + self.assertIn( + "must belong to the same organization", + response.data["devices"][0], + ) def test_superuser_org_auto_set_from_group_and_location(self): org = self._get_org() @@ -1781,12 +1807,7 @@ def test_batch_command_execute_org_mismatched_data_provided(self): self.assertEqual(response.status_code, 400) self.assertEqual( response.data, - { - "devices": [ - "All devices must belong to the same " - "organization as the batch command." - ] - }, + {"devices": ["All devices must belong to the same organization."]}, ) with self.subTest("group org mismatch"): @@ -1872,12 +1893,7 @@ def test_batch_command_dry_run_org_mismatched_data_provided(self): self.assertEqual(response.status_code, 400) self.assertEqual( response.data, - { - "devices": [ - "All devices must belong to the same " - "organization as the batch command." - ] - }, + {"devices": ["All devices must belong to the same organization."]}, ) with self.subTest("group org mismatch"): @@ -2245,20 +2261,29 @@ def test_batch_command_execute_skipped_devices(self): "type": "custom", "input": {"command": "echo test"}, "label": "test-label", - "devices": [str(device_a.pk), str(device_b.pk)], } - response = self.client.post( - execute_url, - data=json.dumps(payload), - content_type="application/json", - ) + with self.assertLogs( + "openwisp_controller.connection.base.models", level="WARNING" + ) as logs: + response = self.client.post( + execute_url, + data=json.dumps(payload), + content_type="application/json", + ) self.assertEqual(response.status_code, 201) + self.assertIn("Skipping device", "\n".join(logs.output)) batch = BatchCommand.objects.get(pk=response.data["batch"]) - self.assertIn(str(device_b.pk), batch.skipped_devices) - self.assertIn( - '"custom" command is not available for this organization', - batch.skipped_devices[str(device_b.pk)][0], + self.assertEqual( + batch.skipped_devices, + { + str(device_b.pk): { + "name": device_b.name, + "error": ( + '"custom" command is not available for' " this organization" + ), + } + }, ) command_qs = Command.objects.filter(batch_command=batch) self.assertTrue(command_qs.filter(device=device_a).exists()) @@ -2291,6 +2316,7 @@ def test_batch_command_execute_skipped_devices(self): self.assertEqual(detail_resp.status_code, 200) self.assertEqual(detail_resp.data["device_count"], 2) self.assertEqual(len(detail_resp.data["devices"]), 2) + self.assertEqual(detail_resp.data["skipped_devices"], batch.skipped_devices) with self.subTest("skipped: no credentials"): device = self._create_device( @@ -2299,26 +2325,33 @@ def test_batch_command_execute_skipped_devices(self): organization=org, ) self._create_config(device=device) - response = self.client.post( - execute_url, - data=json.dumps( - { - "organization": str(org.pk), - "type": "custom", - "input": {"command": "echo test"}, - "label": "test-label", - "devices": [str(device.pk)], - } - ), - content_type="application/json", - ) + with self.assertLogs( + "openwisp_controller.connection.base.models", level="WARNING" + ) as logs: + response = self.client.post( + execute_url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + ), + content_type="application/json", + ) self.assertEqual(response.status_code, 201) batch = BatchCommand.objects.get(pk=response.data["batch"]) - - self.assertIn(str(device.pk), batch.skipped_devices) - self.assertIn( - "Device has no credentials assigned", - batch.skipped_devices[str(device.pk)][0], + self.assertIn("Skipping device", "\n".join(logs.output)) + self.assertEqual( + batch.skipped_devices, + { + str(device.pk): { + "name": device.name, + "error": "Device has no credentials assigned", + } + }, ) detail_url = reverse( "connection_api:batch_command_detail", @@ -2326,7 +2359,9 @@ def test_batch_command_execute_skipped_devices(self): ) detail_response = self.client.get(detail_url) self.assertEqual(detail_response.status_code, 200) - self.assertIn(str(device.pk), detail_response.data["skipped_devices"]) + self.assertEqual( + detail_response.data["skipped_devices"], batch.skipped_devices + ) with self.subTest("skipped: deactivated device"): device = self._create_device( @@ -2341,26 +2376,33 @@ def test_batch_command_execute_skipped_devices(self): ) self._create_device_connection(device=device, credentials=dd_cred) device.deactivate() - response = self.client.post( - execute_url, - data=json.dumps( - { - "organization": str(org.pk), - "type": "custom", - "input": {"command": "echo test"}, - "label": "test-label", - "devices": [str(device.pk)], - } - ), - content_type="application/json", - ) + with self.assertLogs( + "openwisp_controller.connection.base.models", level="WARNING" + ) as logs: + response = self.client.post( + execute_url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + ), + content_type="application/json", + ) self.assertEqual(response.status_code, 201) batch = BatchCommand.objects.get(pk=response.data["batch"]) - - self.assertIn(str(device.pk), batch.skipped_devices) - self.assertIn( - "Device is deactivated", - batch.skipped_devices[str(device.pk)][0], + self.assertIn("Skipping device", "\n".join(logs.output)) + self.assertEqual( + batch.skipped_devices, + { + str(device.pk): { + "name": device.name, + "error": "Device is deactivated", + } + }, ) detail_url = reverse( "connection_api:batch_command_detail", @@ -2368,4 +2410,6 @@ def test_batch_command_execute_skipped_devices(self): ) detail_response = self.client.get(detail_url) self.assertEqual(detail_response.status_code, 200) - self.assertIn(str(device.pk), detail_response.data["skipped_devices"]) + self.assertEqual( + detail_response.data["skipped_devices"], batch.skipped_devices + ) diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index fc45ff2d6..8c50e266e 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -6,6 +6,7 @@ import paramiko from django.contrib.auth.models import ContentType from django.core.exceptions import ValidationError +from django.db import transaction from django.test import TestCase, TransactionTestCase, tag from django.utils import timezone from django.utils.module_loading import import_string @@ -13,6 +14,7 @@ from openwisp_utils.tests import capture_any_output, catch_signal +from .. import handlers from .. import settings as app_settings from ..commands import ( COMMANDS, @@ -26,6 +28,7 @@ from .utils import CreateConnectionsMixin Config = load_model("config", "Config") +LOGGER_NAME = "openwisp_controller.connection.base.models" Device = load_model("config", "Device") Credentials = load_model("connection", "Credentials") DeviceConnection = load_model("connection", "DeviceConnection") @@ -525,6 +528,31 @@ def test_command_is_custom(self): command = Command(type="custom", input={"command": "echo test"}) self.assertTrue(command.is_custom) + def test_command_output_preview(self): + with self.subTest("no output"): + self.assertEqual(Command(type="reboot").output_preview, "") + self.assertEqual(Command(type="reboot", output="").output_preview, "") + + with self.subTest("single line"): + command = Command(type="reboot", output="all good") + self.assertEqual(command.output_preview, "all good") + + with self.subTest("single line with trailing newline"): + command = Command(type="reboot", output="all good\n") + self.assertEqual(command.output_preview, "all good") + + with self.subTest("single long line"): + command = Command(type="reboot", output="x" * 120) + self.assertEqual(command.output_preview, f"… {'x' * 100}") + + with self.subTest("multiple lines"): + command = Command(type="reboot", output="first\nsecond\nlast") + self.assertEqual(command.output_preview, "… last") + + with self.subTest("multiple lines with a long last line"): + command = Command(type="reboot", output=f"first\n{'y' * 120}") + self.assertEqual(command.output_preview, f"… {'y' * 100}") + def test_command_validation(self): dc = self._create_device_connection() command = Command( @@ -1028,6 +1056,133 @@ def test_batch_command_total_devices_successful_failed(self): batch.batch_commands.filter(status="failed", device=device1).exists() ) + def test_batch_command_row_position(self): + org = self._get_org() + dc = self._create_device_connection() + batch = self._create_batch_command(organization=org) + batch.skipped_devices = { + str(uuid4()): {"name": f"device{index}", "error": "failed"} + for index in range(2) + } + batch.save(update_fields=["skipped_devices"]) + with mock.patch.object(Command, "_schedule_command"): + commands = [ + Command.objects.create( + batch_command=batch, + device=dc.device, + connection=dc, + type=batch.type, + input={"command": "echo test"}, + ) + for _ in range(2) + ] + + with self.subTest("the commands are counted without a position"): + self.assertEqual( + BatchCommand.get_command_row_position(commands[1]), + {"index": 1, "affected_devices": 2, "total_rows": 4}, + ) + + with self.subTest("the position passed by the loop is not counted again"): + commands[0]._batch_index = 5 + with self.assertNumQueries(0): + position = BatchCommand.get_command_row_position(commands[0]) + self.assertEqual( + position, {"index": 5, "affected_devices": 6, "total_rows": 8} + ) + + def test_batch_command_skipped_devices(self): + org = self._get_org() + batch = self._create_batch_command(organization=org) + skipped = { + str(uuid4()): {"name": f"device{index}", "error": f"error {index}"} + for index in range(3) + } + pks = list(skipped) + batch.skipped_devices = skipped + batch.save(update_fields=["skipped_devices"]) + + with self.subTest("skipped row"): + row = BatchCommand.build_skipped_row(pks[0], skipped[pks[0]]) + self.assertEqual( + row, + { + "device": pks[0], + "device_name": "device0", + "status": "skipped", + "status_display": "skipped", + "output": "error 0", + "modified": None, + "modified_display": "", + "is_skipped": True, + }, + ) + + with self.subTest("all rows"): + rows = batch.get_skipped_rows() + self.assertEqual([row["device"] for row in rows], pks) + + with self.subTest("sliced rows"): + self.assertEqual( + [row["device"] for row in batch.get_skipped_rows(end=2)], pks[:2] + ) + self.assertEqual( + [row["device"] for row in batch.get_skipped_rows(start=1, end=3)], + pks[1:3], + ) + + with self.subTest("count and device ids"): + self.assertEqual(batch.skipped_count, 3) + self.assertEqual(list(batch.skipped_device_ids), pks) + + with self.subTest("items are not copied when nothing is filtered"): + self.assertEqual(batch.get_skipped_items(), skipped.items()) + + with self.subTest("items filtered by name"): + self.assertEqual( + [pk for pk, _skipped in batch.get_skipped_items(query="DEVICE1")], + pks[1:2], + ) + + with self.subTest("items filtered by device id"): + self.assertEqual( + [pk for pk, _skipped in batch.get_skipped_items(device_ids={pks[2]})], + pks[2:], + ) + + with self.subTest("preview within the limit"): + self.assertEqual( + [row["device"] for row in batch.get_skipped_preview()], pks + ) + + with self.subTest("preview above the limit"): + skipped = { + str(uuid4()): {"name": f"device{index}", "error": f"error {index}"} + for index in range(11) + } + pks = list(skipped) + batch.skipped_devices = skipped + batch.save(update_fields=["skipped_devices"]) + self.assertEqual( + [row["device"] for row in batch.get_skipped_preview()], + pks[:2] + pks[-1:], + ) + + with self.subTest("counts include skipped devices"): + device = self._create_device(organization=org) + self._create_config(device=device) + dc = self._create_device_connection(device=device) + Command.objects.create( + batch_command=batch, + device=device, + connection=dc, + type=batch.type, + input={"command": "echo test"}, + status="success", + ) + self.assertEqual(batch.affected_devices, 1) + self.assertEqual(batch.total_devices, 12) + def test_batch_command_clean_validation(self): org = self._get_org() org2 = self._create_org(name="org2", slug="org2") @@ -1059,7 +1214,42 @@ def test_batch_command_clean_validation(self): batch.clean() self.assertIn("type", ctx.exception.message_dict) self.assertIn( - "not available for this organization", + "not available for the target organization(s)", + ctx.exception.message_dict["type"][0], + ) + + with self.subTest("no command enabled for the organization"): + with mock.patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(uuid4()): ("reboot",)}, + clear=True, + ): + batch = BatchCommand( + organization=org, + type="reboot", + input=None, + label="test-label", + ) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("type", ctx.exception.message_dict) + self.assertIn( + "not available for the target organization(s)", + ctx.exception.message_dict["type"][0], + ) + + with self.subTest("no command enabled system wide"): + with mock.patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(uuid4()): ("reboot",)}, + clear=True, + ): + batch = BatchCommand(type="reboot", input=None, label="test-label") + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("type", ctx.exception.message_dict) + self.assertIn( + "not available for the target organization(s)", ctx.exception.message_dict["type"][0], ) @@ -1136,12 +1326,14 @@ def test_batch_command_create_commands_deactivated_device(self): device = Device.objects.get(pk=device.pk) batch = self._create_batch_command(organization=org) batch.devices.add(device) - batch.create_commands() + with self.assertLogs(LOGGER_NAME, level="WARNING") as logs: + batch.create_commands() + self.assertIn("Skipping device", logs.output[0]) self.assertEqual(batch.batch_commands.count(), 0) self.assertIn(str(device.pk), batch.skipped_devices) self.assertIn( "Device is deactivated", - batch.skipped_devices[str(device.pk)][0], + batch.skipped_devices[str(device.pk)]["error"], ) def test_batch_command_create_commands_no_credentials(self): @@ -1152,12 +1344,14 @@ def test_batch_command_create_commands_no_credentials(self): organization=org, devices=[device], ) - batch.create_commands() + with self.assertLogs(LOGGER_NAME, level="WARNING") as logs: + batch.create_commands() + self.assertIn("Skipping device", logs.output[0]) batch.refresh_from_db() self.assertIn(str(device.pk), batch.skipped_devices) self.assertIn( "Device has no credentials assigned", - batch.skipped_devices[str(device.pk)][0], + batch.skipped_devices[str(device.pk)]["error"], ) def test_batch_command_create_commands_skip_scenarios(self): @@ -1187,12 +1381,16 @@ def test_batch_command_create_commands_skip_scenarios(self): organization=org, devices=[device_a, device_b], ) - batch.create_commands() + with self.assertLogs(LOGGER_NAME, level="WARNING") as logs: + batch.create_commands() + self.assertIn("Skipping device", logs.output[0]) batch.refresh_from_db() self.assertIn(str(device_b.pk), batch.skipped_devices) - self.assertIn( - '"custom" command is not available for this organization', - batch.skipped_devices[str(device_b.pk)][0], + self.assertEqual( + batch.skipped_devices[str(device_b.pk)]["error"], + f'The device was moved out of "{org.name}", the organization' + f' of the mass command "{batch.label}", so the command was' + " not run.", ) db_batch = BatchCommand.objects.get(pk=batch.pk) self.assertEqual(batch.skipped_devices, db_batch.skipped_devices) @@ -1200,6 +1398,25 @@ def test_batch_command_create_commands_skip_scenarios(self): self.assertTrue(command_qs.filter(device=device_a).exists()) self.assertFalse(command_qs.filter(device=device_b).exists()) + with self.subTest("org command not allowed on a system wide batch"): + with mock.patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {"__all__": ("custom",), str(org2.pk): ("reboot",)}, + ): + batch = self._create_batch_command( + organization=None, + devices=[device_b], + ) + with self.assertLogs(LOGGER_NAME, level="WARNING") as logs: + batch.create_commands() + self.assertIn("Skipping device", logs.output[0]) + batch.refresh_from_db() + self.assertIn( + '"custom" command is not available for this organization', + batch.skipped_devices[str(device_b.pk)]["error"], + ) + self.assertFalse(Command.objects.filter(batch_command=batch).exists()) + with self.subTest("mixed skip scenario"): device_ok = self._create_device( name="device-ok", @@ -1230,25 +1447,44 @@ def test_batch_command_create_commands_skip_scenarios(self): organization=org, devices=[device_ok, device_no_creds, device_deactivated], ) - batch.create_commands() + with self.assertLogs(LOGGER_NAME, level="WARNING") as logs: + batch.create_commands() + self.assertEqual(len(logs.output), 2) batch.refresh_from_db() command_qs = Command.objects.filter(batch_command=batch) self.assertEqual(command_qs.count(), 1) self.assertTrue(command_qs.filter(device=device_ok).exists()) self.assertIn(str(device_no_creds.pk), batch.skipped_devices) + self.assertEqual( + batch.skipped_devices[str(device_no_creds.pk)]["name"], + device_no_creds.name, + ) self.assertIn( "Device has no credentials assigned", - batch.skipped_devices[str(device_no_creds.pk)][0], + batch.skipped_devices[str(device_no_creds.pk)]["error"], ) self.assertIn(str(device_deactivated.pk), batch.skipped_devices) self.assertIn( "Device is deactivated", - batch.skipped_devices[str(device_deactivated.pk)][0], + batch.skipped_devices[str(device_deactivated.pk)]["error"], ) self.assertNotIn(str(device_ok.pk), batch.skipped_devices) db_batch = BatchCommand.objects.get(pk=batch.pk) self.assertEqual(batch.skipped_devices, db_batch.skipped_devices) + def test_batch_command_create_commands_without_explicit_devices(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + batch = self._create_batch_command(organization=org) + batch.create_commands() + batch.refresh_from_db() + self.assertEqual( + [command.device for command in batch.batch_commands.all()], [device] + ) + self.assertEqual(list(batch.devices.all()), [device]) + def test_batch_command_resolve_devices(self): org = self._get_org() device1 = self._create_device( @@ -1531,6 +1767,35 @@ def test_batch_command_execute_org_mismatch(self): ctx.exception.message_dict["devices"][0], ) + with self.subTest("devices of different organizations without organization"): + device_org1 = self._create_device( + name="exec-mm-dev-org1", + mac_address="00:11:22:33:44:97", + ) + with self.assertRaises(ValidationError) as ctx: + BatchCommand.execute( + type="custom", + input={"command": "echo test"}, + label="test-label", + devices=[device_org1, device_org2], + ) + self.assertIn("devices", ctx.exception.message_dict) + self.assertIn( + "must belong to the same organization", + ctx.exception.message_dict["devices"][0], + ) + + with self.subTest("a system wide batch accepts devices of different orgs"): + batch = BatchCommand.execute( + type="custom", + input={"command": "echo test"}, + label="test-label", + devices=[device_org1, device_org2], + system_wide=True, + ) + self.assertIsNone(batch.organization_id) + self.assertEqual(set(batch.devices.all()), {device_org1, device_org2}) + with self.subTest("group org mismatch"): group_org2 = DeviceGroup.objects.create( name="exec-mm-group", @@ -1593,6 +1858,19 @@ def test_batch_command_dry_run_org_mismatch(self): ctx.exception.message_dict["devices"][0], ) + with self.subTest("devices of different organizations without organization"): + device_org1 = self._create_device( + name="dry-mm-dev-org1", + mac_address="00:11:22:33:44:97", + ) + with self.assertRaises(ValidationError) as ctx: + BatchCommand.dry_run(devices=[device_org1, device_org2]) + self.assertIn("devices", ctx.exception.message_dict) + self.assertIn( + "must belong to the same organization", + ctx.exception.message_dict["devices"][0], + ) + with self.subTest("group org mismatch"): group_org2 = DeviceGroup.objects.create( name="dry-mm-group", @@ -1720,7 +1998,9 @@ def test_batch_command_calculate_and_update_status(self): with self.subTest("all success with skipped shows failed"): batch3 = self._create_batch_command(organization=org) - batch3.skipped_devices = {str(device.pk): ["no credentials"]} + batch3.skipped_devices = { + str(device.pk): {"name": device.name, "error": "no credentials"} + } batch3.save(update_fields=["skipped_devices"]) Command.objects.create( batch_command=batch3, @@ -1740,6 +2020,132 @@ def test_batch_command_calculate_and_update_status(self): batch2.refresh_from_db() self.assertEqual(batch2.modified, initial_modified) + def test_batch_command_status_rejects_a_stale_calculation(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + dc = self._create_device_connection(device=device) + batch = self._create_batch_command(organization=org) + Command.objects.create( + batch_command=batch, + device=device, + connection=dc, + type=batch.type, + input={"command": "echo test"}, + status="success", + ) + BatchCommand.objects.filter(pk=batch.pk).update(status="in-progress") + batch.refresh_from_db() + original = BatchCommand._compute_status + calls = [] + + def compute(self): + calls.append(self.status) + if len(calls) == 1: + BatchCommand.objects.filter(pk=batch.pk).update( + status="failed", + skipped_devices={ + str(uuid4()): {"name": "device1", "error": "no credentials"} + }, + ) + return "success" + return original(self) + + with mock.patch.object(BatchCommand, "_compute_status", compute): + batch.calculate_and_update_status() + batch.refresh_from_db() + self.assertEqual(len(calls), 1) + self.assertEqual(batch.status, "failed") + + def test_batch_command_status_is_written_once(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + dc = self._create_device_connection(device=device) + batch = self._create_batch_command(organization=org) + with mock.patch.object(Command, "_schedule_command"): + Command.objects.create( + batch_command=batch, + device=device, + connection=dc, + type=batch.type, + input={"command": "echo test"}, + status="success", + ) + BatchCommand.objects.filter(pk=batch.pk).update(status="in-progress") + batch.refresh_from_db() + modified = batch.modified + + with mock.patch.object(BatchCommand, "save") as save: + with mock.patch.object( + handlers, "send_batch_command_websocket_event" + ) as publish: + batch.calculate_and_update_status() + save.assert_not_called() + publish.assert_called_once() + group, payload = publish.call_args[0] + self.assertEqual(group, f"config.batchcommand-{batch.pk}") + self.assertEqual(payload["status"], "success") + self.assertEqual(payload["type"], "batch_status") + batch.refresh_from_db() + self.assertEqual(batch.status, "success") + self.assertGreater(batch.modified, modified) + + def test_batch_command_device_transferred_to_another_org(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device = self._create_device(organization=org) + self._create_config(device=device) + dc = self._create_device_connection(device=device) + batch = self._create_batch_command(organization=org) + batch.devices.set([device]) + + with self.subTest("the transferred device is skipped at creation"): + device.organization = org2 + device.save(update_fields=["organization"]) + with mock.patch.object(Command, "_schedule_command"): + with self.assertLogs(LOGGER_NAME, level="WARNING") as logs: + batch.create_commands() + batch.refresh_from_db() + self.assertIn("Skipping device", logs.output[0]) + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertEqual( + batch.skipped_devices[str(device.pk)]["error"], + f'The device was moved out of "{org.name}", the organization of' + f' the mass command "{batch.label}", so the command was not run.', + ) + self.assertFalse(batch.batch_commands.exists()) + + with self.subTest("a command of a transferred device is not executed"): + device.organization = org + device.save(update_fields=["organization"]) + with mock.patch.object(Command, "_schedule_command"): + command = Command.objects.create( + batch_command=batch, + device=device, + connection=dc, + type=batch.type, + input={"command": "echo test"}, + ) + device.organization = org2 + device.save(update_fields=["organization"]) + with mock.patch.object(Command, "_exec_command") as exec_command: + with self.assertLogs(LOGGER_NAME, level="WARNING") as logs: + command.execute() + exec_command.assert_not_called() + self.assertIn( + f"Not executing command {command.pk} of batch {batch.pk}", + logs.output[0], + ) + self.assertIn(str(org2.pk), logs.output[0]) + command.refresh_from_db() + self.assertEqual(command.status, "failed") + self.assertIn( + f'The device was moved out of "{org.name}", the organization of' + f' the mass command "{batch.label}", so the command was not run.', + command.output, + ) + def test_batch_command_permissions(self): ct = ContentType.objects.get_by_natural_key( app_label=self.app_label, model="batchcommand" @@ -2070,3 +2476,51 @@ def test_chunk_size(self): credential = self._create_credentials( name="Mocked Credential", auto_add=True, organization=org ) + + @mock.patch.object(Command, "_schedule_command") + def test_batch_command_broadcast_deferred_to_commit(self, schedule_command): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + dc = self._create_device_connection(device=device) + batch = self._create_batch_command(organization=org) + command_opts = dict( + batch_command=batch, + device=device, + connection=dc, + type=batch.type, + input={"command": "echo test"}, + ) + + def channel_layer(): + layer = mock.MagicMock() + layer.group_send = mock.AsyncMock() + return layer + + with self.subTest("nothing is sent before the transaction commits"): + layer = channel_layer() + with mock.patch.object( + handlers.layers, "get_channel_layer", return_value=layer + ): + with transaction.atomic(): + command = Command.objects.create(**command_opts) + layer.group_send.assert_not_called() + layer.group_send.assert_called_once() + group, event = layer.group_send.call_args[0] + self.assertEqual(group, f"config.batchcommand-{batch.pk}") + self.assertEqual(event["type"], "send.update") + self.assertEqual(event["data"]["type"], "command_update") + self.assertEqual(event["data"]["id"], str(command.pk)) + self.assertEqual(event["data"]["device_name"], device.name) + + with self.subTest("a rolled back command is never broadcast"): + layer = channel_layer() + with mock.patch.object( + handlers.layers, "get_channel_layer", return_value=layer + ): + with self.assertRaises(ValueError): + with transaction.atomic(): + Command.objects.create(**command_opts) + raise ValueError() + layer.group_send.assert_not_called() + self.assertEqual(batch.batch_commands.count(), 1) diff --git a/openwisp_controller/connection/tests/test_selenium.py b/openwisp_controller/connection/tests/test_selenium.py index 82dde1c8b..9d37f075f 100644 --- a/openwisp_controller/connection/tests/test_selenium.py +++ b/openwisp_controller/connection/tests/test_selenium.py @@ -1,16 +1,50 @@ from time import sleep +from unittest.mock import patch +from urllib.parse import quote, urlparse +from uuid import UUID +from channels.testing import ChannelsLiveServerTestCase +from django.apps import apps as django_apps +from django.conf import settings as django_settings +from django.contrib.auth.models import Permission from django.contrib.staticfiles.testing import StaticLiveServerTestCase -from django.test import tag +from django.test import override_settings, tag from django.urls import reverse from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait from swapper import load_model +from openwisp_users.migrations import ( + allow_operator_view_organization, + create_default_groups, +) from openwisp_utils.tests import SeleniumTestMixin -from .utils import CreateConnectionsMixin +from ...config import migrations as config_migrations +from ...config.tests.utils import CreateDeviceGroupMixin +from ...geo import migrations as geo_migrations +from ...geo.tests.utils import TestGeoMixin +from .. import migrations as connection_migrations +from .. import settings as app_settings +from ..commands import ( + COMMANDS, + ORGANIZATION_COMMAND_SCHEMA, + ORGANIZATION_ENABLED_COMMANDS, + register_command, + unregister_command, +) +from ..utils import format_localized_datetime +from .utils import CreateConnectionsMixin, SshServer, _uci_show_command_callable +BatchCommand = load_model("connection", "BatchCommand") Command = load_model("connection", "Command") +Device = load_model("config", "Device") +Location = load_model("geo", "Location") +DeviceLocation = load_model("geo", "DeviceLocation") +Group = load_model("openwisp_users", "Group") +SCOPED_ORGANIZATION_ID = "11111111-1111-1111-1111-111111111111" +DEFAULT_ORGANIZATION_ID = "22222222-2222-2222-2222-222222222222" @tag("selenium_tests") @@ -64,3 +98,1432 @@ def test_command_widget_on_device(self): "#tabs-container li.recent.commands", ) self.assertEqual(Command.objects.count(), 1) + + +@tag("selenium_tests") +@override_settings( + CHANNEL_LAYERS={ + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": {"hosts": [f"{django_settings.REDIS_URL}/7"]}, + } + } +) +class TestBatchCommandAdmin( + TestGeoMixin, + CreateDeviceGroupMixin, + CreateConnectionsMixin, + SeleniumTestMixin, + ChannelsLiveServerTestCase, +): + app_label = "connection" + config_app_label = "config" + object_model = Device + location_model = Location + object_location_model = DeviceLocation + + @classmethod + def setUpClass(cls): + if "uci_show" not in COMMANDS: + register_command( + "uci_show", + { + "label": "UCI show", + "schema": { + "title": "UCI show", + "type": "object", + "required": ["config"], + "properties": { + "config": { + "type": "string", + "title": "Config", + "minLength": 1, + "pattern": ".", + } + }, + "message": "Config cannot be empty.", + "additionalProperties": False, + }, + "callable": _uci_show_command_callable, + }, + ) + cls.addClassCleanup(unregister_command, "uci_show") + ORGANIZATION_ENABLED_COMMANDS[SCOPED_ORGANIZATION_ID] = ( + "custom", + "reboot", + "change_password", + "uci_show", + ) + ORGANIZATION_ENABLED_COMMANDS[DEFAULT_ORGANIZATION_ID] = ( + "custom", + "reboot", + "change_password", + ) + for organization_id in (SCOPED_ORGANIZATION_ID, DEFAULT_ORGANIZATION_ID): + ORGANIZATION_COMMAND_SCHEMA[organization_id] = { + command: COMMANDS[command]["schema"] + for command in ORGANIZATION_ENABLED_COMMANDS[organization_id] + } + cls.addClassCleanup( + ORGANIZATION_ENABLED_COMMANDS.pop, organization_id, None + ) + cls.addClassCleanup(ORGANIZATION_COMMAND_SCHEMA.pop, organization_id, None) + super().setUpClass() + cls.mock_ssh_server = SshServer( + {"root": cls._TEST_RSA_PRIVATE_KEY_PATH} + ).__enter__() + cls.addClassCleanup(cls.mock_ssh_server.__exit__) + cls.ssh_server.port = cls.mock_ssh_server.port + + def setUp(self): + super().setUp() + self._restore_default_groups() + self.execute_url = reverse(f"admin:{self.app_label}_batchcommand_execute") + self.confirm_url = reverse(f"admin:{self.app_label}_batchcommand_confirm") + self.changelist_url = reverse(f"admin:{self.app_label}_batchcommand_changelist") + + # this is a TransactionTestCase: the flush performed after every test + # restores what post_migrate creates (permissions, content types) but + # not the rows written by data migrations, so the default groups are + # lost and _create_operator() would return a user without permissions + def _restore_default_groups(self): + if Group.objects.filter(name="Operator").exists(): + return + models_modules = { + app_config.label: app_config.models_module + for app_config in django_apps.get_app_configs() + } + try: + for migration in ( + create_default_groups, + allow_operator_view_organization, + config_migrations.assign_permissions_to_groups, + config_migrations.assign_devicegroup_permissions_to_groups, + geo_migrations.assign_permissions_to_groups, + connection_migrations.assign_permissions_to_groups, + connection_migrations.assign_command_permissions_to_groups, + connection_migrations.assign_batchcommand_permissions_to_groups, + ): + migration(django_apps, None) + finally: + for app_config in django_apps.get_app_configs(): + app_config.models_module = models_modules[app_config.label] + + def _create_devices(self, organization, count, credentials=None): + if credentials is None: + credentials = self._create_credentials( + organization=organization, + params={"username": "root", "password": "password", "port": 5555}, + ) + devices = [] + for index in range(count): + device = self._create_device( + name=f"device-{index:03d}", + organization=organization, + mac_address="00:11:22:33:{:02x}:{:02x}".format( + index // 256, index % 256 + ), + ) + self._create_device_connection( + device=device, + credentials=credentials, + update_strategy=app_settings.UPDATE_STRATEGIES[0][0], + ) + devices.append(device) + return devices + + def _select2(self, field_id, text): + self.find_element( + by=By.CSS_SELECTOR, value=f"#select2-{field_id}-container" + ).click() + self.find_element( + by=By.CSS_SELECTOR, + value=".select2-container--open .select2-search__field", + ).send_keys(text) + self.find_element( + by=By.CSS_SELECTOR, + value=".select2-container--open .select2-results__option--highlighted", + timeout=5, + ).click() + self.wait_for_invisibility(By.CSS_SELECTOR, ".select2-container--open") + + def _fill_wizard( + self, + type, + label, + organization=None, + group=None, + location=None, + command_input=None, + open_page=True, + ): + if open_page: + self.open(self.execute_url) + self._wait_for_url(self.execute_url) + self.hide_loading_overlay() + self.web_driver.execute_script( + "django.jQuery('#id_group, #id_location').val('').trigger('change');" + ) + self._select2("id_type", type) + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value="#select2-id_type-container" + ).get_attribute("title"), + type, + ) + for field_name, value in (command_input or {}).items(): + field = self.find_element( + by=By.CSS_SELECTOR, + value=f"#id_input_jsoneditor [name='root[{field_name}]']", + timeout=5, + ) + field.clear() + field.send_keys(value) + self.assertEqual(field.get_attribute("value"), value) + label_field = self.find_element(by=By.ID, value="id_label") + label_field.clear() + label_field.send_keys(label) + self.assertEqual(label_field.get_attribute("value"), label) + for field_id, target in ( + ("id_organization", organization), + ("id_group", group), + ("id_location", location), + ): + if target: + self._select2(field_id, target.name) + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value=f"#select2-{field_id}-container" + ).get_attribute("title"), + target.name, + ) + + def _filter_by(self, title, option): + current_url = self.web_driver.current_url + tables = self.web_driver.find_elements(By.CSS_SELECTOR, "#result_list") + slug = title.replace(" ", "-") + filter_element = self.find_element( + by=By.CSS_SELECTOR, value=f".ow-filter.{slug}", wait_for="presence" + ) + self.web_driver.execute_script( + "arguments[0].click();", + filter_element.find_element(By.CSS_SELECTOR, ".filter-title"), + ) + WebDriverWait(self.web_driver, 5).until( + lambda driver: "ow-active" in filter_element.get_attribute("class") + ) + self.web_driver.execute_script( + "arguments[0].click();", + self.find_element( + by=By.XPATH, + value=( + "//div[contains(@class, 'ow-filter')]" + f"[contains(@class, '{slug}')]" + "//div[contains(@class, 'filter-options')]" + f"//a[normalize-space()='{option}']" + ), + wait_for="presence", + ), + ) + apply_filters = self.web_driver.find_elements(By.ID, "ow-apply-filter") + if apply_filters: + apply_filters[0].click() + WebDriverWait(self.web_driver, 5).until( + lambda driver: driver.current_url != current_url + ) + if tables: + WebDriverWait(self.web_driver, 5).until(EC.staleness_of(tables[0])) + self.hide_loading_overlay() + + def _open_autocomplete_filter(self, param_name): + self.find_element( + by=By.XPATH, + value=( + f"//div[@id='ow-changelist-filter']//select[@name='{param_name}']" + "/following-sibling::span[contains(@class, 'select2')]" + ), + ).click() + self.wait_for_invisibility( + By.CSS_SELECTOR, ".select2-results__option.loading-results" + ) + + def _autocomplete_options(self, param_name): + self._open_autocomplete_filter(param_name) + options = [ + option.text + for option in self.find_elements( + by=By.CSS_SELECTOR, + value=".select2-container--open .select2-results__option", + ) + ] + self.find_element(by=By.CSS_SELECTOR, value="#content").click() + return options + + def _filter_options(self, title): + slug = title.replace(" ", "-") + return [ + option.get_attribute("textContent").strip() + for option in self.web_driver.find_elements( + By.CSS_SELECTOR, f".ow-filter.{slug} .filter-options a" + ) + ] + + def _changelist_labels(self): + return [ + row.find_element(By.CSS_SELECTOR, "th.field-label").text + for row in self._rows() + ] + + def _select_options(self, field_id): + return [ + option.text + for option in self.find_elements( + by=By.CSS_SELECTOR, + value=f"#{field_id} option", + wait_for="presence", + ) + if option.get_attribute("value") + ] + + def _wait_for_url(self, path, timeout=None): + WebDriverWait(self.web_driver, timeout or 5).until( + lambda driver: urlparse(driver.current_url).path == path + ) + self.assertEqual(urlparse(self.web_driver.current_url).path, path) + + def _wait_for_review_page(self): + self._wait_for_url(self.confirm_url) + self.wait_for_visibility(By.CSS_SELECTOR, ".command-summary", timeout=5) + + def _wait_for_batch_result(self, label, status, count): + batch = BatchCommand.objects.get(label=label) + self._wait_for_url( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]), + # the test env runs celery synchronously, + # so 50 commands take a while + timeout=30, + ) + self.wait_for_visibility( + By.CSS_SELECTOR, "ul.messagelist li.success", timeout=5 + ) + self.wait_for_visibility( + By.CSS_SELECTOR, + f".field-colored_status .command-status.{status}", + timeout=5, + ) + WebDriverWait(self.web_driver, 5).until( + lambda driver: self._command_statuses() == [status] * count, + message=( + f'expected {count} command(s) with status "{status}",' + f" got {self._command_statuses()}" + ), + ) + + def _rows(self): + return self.find_elements(by=By.CSS_SELECTOR, value="#result_list tbody tr") + + def _device_rows(self): + return self.web_driver.find_elements( + By.CSS_SELECTOR, "#result_list tbody tr[data-device-pk]" + ) + + def _device_names(self): + return [ + row.find_element(By.CSS_SELECTOR, "th.field-name").text + for row in self._rows() + ] + + def _command_device_names(self): + return [ + row.find_element(By.CSS_SELECTOR, "td:first-child").text + for row in self._rows() + ] + + def _command_statuses(self): + return [ + row.find_element(By.CSS_SELECTOR, ".command-status").text + for row in self._rows() + ] + + def _summary(self): + summary = {} + for row in self.find_elements( + by=By.CSS_SELECTOR, value=".command-summary .form-row" + ): + summary[row.find_element(By.TAG_NAME, "label").text] = row.find_element( + By.CSS_SELECTOR, ".readonly" + ).text.replace("\xa0", " ") + return summary + + def test_execute_batch_command(self): + """Walks the whole wizard for every command type and every target. + + Covers custom, reboot and change password commands, the organization, + device group and location targets, the review page summary and device + list, the exclusion of unticked devices, and the results rendered on + the batch page after execution. + """ + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + devices1 = self._create_devices(org1, 5) + devices2 = self._create_devices( + org2, + 2, + credentials=self._create_credentials_with_key( + organization=org2, port=self.ssh_server.port + ), + ) + grouped_device, located_device, *_ = devices1 + group1 = self._create_device_group(name="group1", organization=org1) + location1 = self._create_location(name="location1", organization=org1) + group2 = self._create_device_group(name="group2", organization=org2) + location2 = self._create_location(name="location2", organization=org2) + grouped_device.group = group1 + grouped_device.full_clean() + grouped_device.save() + self._create_object_location(content_object=located_device, location=location1) + self.login() + + with self.subTest("custom command"): + self._fill_wizard( + type="Custom commands", + label="small-custom", + organization=org2, + command_input={"command": "echo test"}, + ) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Type"], "Custom commands") + self.assertEqual(summary["Command"], "echo test") + self.assertEqual(summary["Label"], "small-custom") + self.assertEqual(summary["Targets"], org2.name) + self.assertEqual(summary["Will run on"], "2 devices") + self.assertEqual(self._device_names(), [device.name for device in devices2]) + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("small-custom", "success", 2) + label = self.find_element( + by=By.CSS_SELECTOR, value=".field-label .readonly" + ) + command_type = self.find_element( + by=By.CSS_SELECTOR, value=".field-type .readonly" + ) + command_input = self.find_element( + by=By.CSS_SELECTOR, value=".field-formatted_input .readonly" + ) + affected_devices = self.find_element( + by=By.CSS_SELECTOR, value=".field-affected_devices .readonly" + ) + self.assertEqual(label.text, "small-custom") + self.assertEqual(command_type.text, "Custom commands") + self.assertEqual(command_input.text, "echo test") + self.assertEqual(affected_devices.text, "2") + self.assertEqual( + sorted(self._command_device_names()), + sorted(device.name for device in devices2), + ) + self.assertEqual(self._command_statuses(), ["success", "success"]) + + with self.subTest("reboot"): + self._fill_wizard(type="Reboot", label="small-reboot", organization=org1) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Type"], "Reboot") + self.assertNotIn("Command", summary) + self.assertEqual(summary["Will run on"], "5 devices") + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("small-reboot", "failed", 5) + command_type = self.find_element( + by=By.CSS_SELECTOR, value=".field-type .readonly" + ) + command_input = self.find_element( + by=By.CSS_SELECTOR, value=".field-formatted_input .readonly" + ) + affected_devices = self.find_element( + by=By.CSS_SELECTOR, value=".field-affected_devices .readonly" + ) + self.assertEqual(command_type.text, "Reboot") + self.assertEqual(command_input.text, "-") + self.assertEqual(affected_devices.text, "5") + self.assertEqual( + sorted(self._command_device_names()), + sorted(device.name for device in devices1), + ) + self.assertEqual(self._command_statuses(), ["failed"] * 5) + + with self.subTest("change password"): + self._fill_wizard( + type="Change password", + label="small-password", + organization=org1, + command_input={ + "password": "tester123", + "confirm_password": "tester123", + }, + ) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Type"], "Change password") + self.assertNotIn("Command", summary) + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("small-password", "failed", 5) + command_type = self.find_element( + by=By.CSS_SELECTOR, value=".field-type .readonly" + ) + command_input = self.find_element( + by=By.CSS_SELECTOR, value=".field-formatted_input .readonly" + ) + self.assertEqual(command_type.text, "Change password") + self.assertEqual(command_input.text, "********") + self.assertEqual(self._command_statuses(), ["failed"] * 5) + + with self.subTest("device group target"): + self._fill_wizard( + type="Reboot", + label="small-group", + organization=org1, + group=group1, + ) + group_options = self._select_options("id_group") + self.assertEqual(group_options, [group1.name]) + self.assertNotIn(group2.name, group_options) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Targets"], f"{org1.name}, {group1.name}") + self.assertEqual(summary["Will run on"], "1 device") + self.assertEqual(self._device_names(), [grouped_device.name]) + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("small-group", "failed", 1) + self.assertEqual(self._command_device_names(), [grouped_device.name]) + self.assertEqual(self._command_statuses(), ["failed"]) + + with self.subTest("location target"): + self._fill_wizard( + type="Reboot", + label="small-location", + organization=org1, + location=location1, + ) + location_options = self._select_options("id_location") + self.assertEqual(location_options, [location1.name]) + self.assertNotIn(location2.name, location_options) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Targets"], f"{org1.name}, {location1.name}") + self.assertEqual(summary["Will run on"], "1 device") + self.assertEqual(self._device_names(), [located_device.name]) + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("small-location", "failed", 1) + self.assertEqual(self._command_device_names(), [located_device.name]) + self.assertEqual(self._command_statuses(), ["failed"]) + + with self.subTest("excluded devices"): + self._fill_wizard(type="Reboot", label="small-excluded", organization=org1) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + checkbox = self.find_element( + by=By.CSS_SELECTOR, value="#result_list tbody .device-checkbox" + ) + excluded_pk = checkbox.get_attribute("value") + checkbox.click() + selected_count = self.find_element(by=By.ID, value="selected-count") + execute_button = self.find_element(by=By.ID, value="execute-button") + excluded_field = self.find_element( + by=By.ID, value="id_excluded", wait_for="presence" + ) + self.assertEqual(selected_count.text, "4") + self.assertEqual(execute_button.text, "Execute on 4 devices") + self.assertEqual(excluded_field.get_attribute("value"), excluded_pk) + execute_button.click() + self._wait_for_batch_result("small-excluded", "failed", 4) + affected_devices = self.find_element( + by=By.CSS_SELECTOR, value=".field-affected_devices .readonly" + ) + device_pks = [row.get_attribute("data-device-pk") for row in self._rows()] + self.assertEqual(affected_devices.text, "4") + self.assertEqual(self._command_statuses(), ["failed"] * 4) + self.assertNotIn(excluded_pk, device_pks) + + with self.subTest("the exclusions do not survive a changed target set"): + self._fill_wizard( + type="Reboot", + label="small-swapped", + organization=org1, + group=group1, + ) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + self.assertEqual(self._device_names(), [grouped_device.name]) + self.find_element( + by=By.CSS_SELECTOR, value="#result_list tbody .device-checkbox" + ).click() + selected_count = self.find_element(by=By.ID, value="selected-count") + self.assertEqual(selected_count.text, "0") + grouped_device.group = None + grouped_device.full_clean() + grouped_device.save() + located_device.group = group1 + located_device.full_clean() + located_device.save() + self.open(self.confirm_url) + self._wait_for_review_page() + self.hide_loading_overlay() + selected_count = self.find_element(by=By.ID, value="selected-count") + execute_button = self.find_element(by=By.ID, value="execute-button") + excluded_field = self.find_element( + by=By.ID, value="id_excluded", wait_for="presence" + ) + self.assertEqual(self._device_names(), [located_device.name]) + self.assertEqual(selected_count.text, "1") + self.assertEqual(execute_button.text, "Execute on 1 device") + self.assertEqual(execute_button.get_attribute("disabled"), None) + self.assertEqual(excluded_field.get_attribute("value"), "") + execute_button.click() + self._wait_for_batch_result("small-swapped", "failed", 1) + self.assertEqual(self._command_device_names(), [located_device.name]) + + self.assertEqual(self.get_browser_errors(), []) + + def test_execute_large_batch(self): + org1 = self._get_org() + devices = self._create_devices(org1, 50) + device_names = [device.name for device in devices] + per_page = 20 + second_page_end = per_page * 2 + first_page = device_names[:per_page] + second_page = device_names[per_page:second_page_end] + self.login() + self._fill_wizard(type="Reboot", label="large-reboot", organization=org1) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Type"], "Reboot") + self.assertNotIn("Command", summary) + self.assertEqual(summary["Label"], "large-reboot") + self.assertEqual(summary["Targets"], org1.name) + self.assertEqual(summary["Will run on"], "50 devices") + + with self.subTest("the device table is paginated"): + selected_count = self.find_element(by=By.ID, value="selected-count") + paginator = self.find_element(by=By.CSS_SELECTOR, value=".paginator") + self.assertEqual(selected_count.text, "50") + self.assertEqual(self._device_names(), first_page) + self.assertIn("50 devices", paginator.text.lower()) + + with self.subTest("select all toggles the devices of the page"): + select_all = self.find_element(by=By.ID, value="select-all-devices") + selected_count = self.find_element(by=By.ID, value="selected-count") + select_all.click() + self.assertEqual(selected_count.text, "30") + select_all.click() + self.assertEqual(selected_count.text, "50") + + with self.subTest("an exclusion survives pagination and the batch"): + checkbox = self.find_element( + by=By.CSS_SELECTOR, value="#result_list tbody .device-checkbox" + ) + excluded_pk = checkbox.get_attribute("value") + excluded_name = checkbox.get_attribute("aria-label").replace("Include ", "") + checkbox.click() + selected_count = self.find_element(by=By.ID, value="selected-count") + self.assertEqual(selected_count.text, "49") + self.open(f"{self.confirm_url}?p=2") + self._wait_for_url(self.confirm_url) + self.hide_loading_overlay() + selected_count = self.find_element(by=By.ID, value="selected-count") + excluded_field = self.find_element( + by=By.ID, value="id_excluded", wait_for="presence" + ) + self.assertEqual(self._device_names(), second_page) + self.assertEqual(selected_count.text, "49") + self.assertEqual(excluded_field.get_attribute("value"), excluded_pk) + self.open(self.confirm_url) + self._wait_for_url(self.confirm_url) + self.hide_loading_overlay() + checkbox = self.find_element( + by=By.CSS_SELECTOR, value="#result_list tbody .device-checkbox" + ) + self.assertEqual(checkbox.is_selected(), False) + + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("large-reboot", "failed", 20) + label = self.find_element( + by=By.CSS_SELECTOR, value=".field-label .readonly" + ) + command_type = self.find_element( + by=By.CSS_SELECTOR, value=".field-type .readonly" + ) + command_input = self.find_element( + by=By.CSS_SELECTOR, value=".field-formatted_input .readonly" + ) + affected_devices = self.find_element( + by=By.CSS_SELECTOR, value=".field-affected_devices .readonly" + ) + paginator = self.find_element(by=By.CSS_SELECTOR, value=".paginator") + device_pks = [row.get_attribute("data-device-pk") for row in self._rows()] + command_names = self._command_device_names() + self.assertEqual(label.text, "large-reboot") + self.assertEqual(command_type.text, "Reboot") + self.assertEqual(command_input.text, "-") + self.assertEqual(affected_devices.text, "49") + self.assertEqual(len(command_names), 20) + self.assertEqual(paginator.text, "49 commands") + self.assertEqual(self._command_statuses(), ["failed"] * 20) + self.assertEqual( + set(command_names) - {device.name for device in devices}, set() + ) + self.assertNotIn(excluded_name, command_names) + self.assertNotIn(excluded_pk, device_pks) + self.assertEqual(self.get_browser_errors(), []) + + def test_batch_command_live_updates(self): + """Rows, counters, output and status update over the websocket. + + The page is opened while the batch is still running and is never + reloaded: every assertion below is satisfied only if batch-command.js + applies the pushed updates to the DOM. + """ + org = self._get_org() + device, second_device = self._create_devices(org, 2) + batch = self._create_batch_command(organization=org, label="live-batch") + batch.status = "in-progress" + batch.save(update_fields=["status"]) + self.login() + self.open( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + ) + self.hide_loading_overlay() + self.wait_for_visibility(By.CSS_SELECTOR, "#result_list") + self.assertEqual(self._device_rows(), []) + + with patch.object(Command, "_schedule_command"): + command = Command.objects.create( + batch_command=batch, + device=device, + type="custom", + input={"command": "echo test"}, + ) + + WebDriverWait(self.web_driver, 10).until( + lambda driver: self._command_device_names() == [device.name], + message="the created command was never pushed to the page", + ) + self.assertEqual(self._command_statuses(), ["in progress"]) + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value=".field-affected_devices .readonly" + ).text, + "1", + ) + command.status = "success" + command.output = "live output" + command._save_without_resurrecting() + + WebDriverWait(self.web_driver, 10).until( + lambda driver: self._command_statuses() == ["success"], + message="the completed command was never pushed to the page", + ) + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value="#result_list .command-output pre" + ).text, + "live output", + ) + WebDriverWait(self.web_driver, 10).until( + lambda driver: self.find_element( + by=By.CSS_SELECTOR, value=".field-colored_status .command-status" + ).text + == "success", + message="the batch status was never pushed to the page", + ) + + with patch.object(Command, "_schedule_command"): + Command.objects.create( + batch_command=batch, + device=second_device, + type="custom", + input={"command": "echo test"}, + ) + + WebDriverWait(self.web_driver, 10).until( + lambda driver: sorted(self._command_device_names()) + == sorted([device.name, second_device.name]), + message="the second command was never pushed to the page", + ) + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value=".results-container .paginator" + ).text, + "2 commands", + ) + + command.refresh_from_db() + pushed = self.find_element( + by=By.CSS_SELECTOR, + value=f"#batch-command-row-{command.device_id} td:last-child", + ).text + self.assertEqual(pushed, format_localized_datetime(command.modified)) + self.open( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + ) + self.hide_loading_overlay() + self.wait_for_visibility(By.CSS_SELECTOR, "#result_list") + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, + value=f"#batch-command-row-{command.device_id} td:last-child", + ).text, + pushed, + ) + self.assertEqual(self.get_browser_errors(), []) + + def test_batch_command_reconnect_on_a_filtered_page(self): + org = self._get_org() + devices = self._create_devices(org, 3) + batch = self._create_batch_command(organization=org, label="filtered-batch") + commands = [] + for index, device in enumerate(devices): + command = Command( + batch_command=batch, + device=device, + type="custom", + input={"command": "echo test"}, + ) + command.full_clean() + with patch.object(Command, "_schedule_command"): + command.save() + command.status = "failed" if index else "success" + command._save_without_resurrecting() + commands.append(command) + self.login() + url = "{}?status=failed".format( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + ) + self.open(url) + self.hide_loading_overlay() + self.wait_for_visibility(By.CSS_SELECTOR, "#result_list tbody tr") + failed_names = sorted(command.device.name for command in commands[1:]) + self.assertEqual(sorted(self._command_device_names()), failed_names) + self.assertEqual( + self.find_element(by=By.CSS_SELECTOR, value=".paginator").text, + "2 commands", + ) + self.web_driver.execute_script( + "document.querySelector('#result_list tbody tr').remove();" + ) + self.assertEqual(len(self._rows()), 1) + self.web_driver.execute_script("window.batchCommandWebSocket.refresh();") + WebDriverWait(self.web_driver, 10).until( + lambda driver: len( + driver.find_elements(By.CSS_SELECTOR, "#result_list tbody tr") + ) + == 2 + ) + self.assertEqual(sorted(self._command_device_names()), failed_names) + self.assertEqual( + self.find_element(by=By.CSS_SELECTOR, value=".paginator").text, + "2 commands", + ) + self.assertEqual(self.get_browser_errors(), []) + + def test_batch_command_live_updates_on_a_filtered_page(self): + org = self._get_org() + devices = self._create_devices(org, 3) + batch = self._create_batch_command(organization=org, label="filtered-live") + commands = [] + for index, device in enumerate(devices): + command = Command( + batch_command=batch, + device=device, + type="custom", + input={"command": "echo test"}, + ) + command.full_clean() + with patch.object(Command, "_schedule_command"): + command.save() + command.status = "failed" if index else "success" + command._save_without_resurrecting() + commands.append(command) + self.login() + self.open( + "{}?status=failed".format( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + ) + ) + self.hide_loading_overlay() + self.wait_for_visibility(By.CSS_SELECTOR, "#result_list tbody tr") + self.assertEqual( + sorted(self._command_device_names()), + sorted(command.device.name for command in commands[1:]), + ) + commands[0].status = "failed" + commands[0]._save_without_resurrecting() + WebDriverWait(self.web_driver, 10).until( + lambda driver: len(self._device_rows()) == 3, + message="the command which started matching the filter was never added", + ) + self.assertEqual( + sorted(self._command_device_names()), + sorted(command.device.name for command in commands), + ) + commands[1].status = "success" + commands[1]._save_without_resurrecting() + WebDriverWait(self.web_driver, 10).until( + lambda driver: len(self._device_rows()) == 2, + message="the command which stopped matching the filter was never removed", + ) + self.assertEqual( + sorted(self._command_device_names()), + sorted(command.device.name for command in [commands[0], commands[2]]), + ) + self.assertEqual(self.get_browser_errors(), []) + + def test_batch_command_organization_isolation_and_permissions(self): + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + devices1 = self._create_devices(org1, 2) + self._create_devices(org2, 2) + group1 = self._create_device_group(name="group1", organization=org1) + group2 = self._create_device_group(name="group2", organization=org2) + location1 = self._create_location(name="location1", organization=org1) + location2 = self._create_location(name="location2", organization=org2) + batch1 = self._create_batch_command(organization=org1, label="managed-batch") + batch2 = self._create_batch_command(organization=org2, label="unmanaged-batch") + operator = self._create_operator(organizations=[org1]) + self.login(username=operator.username, password="tester") + + with self.subTest("the wizard only offers the managed organization"): + self.open(self.execute_url) + self._wait_for_url(self.execute_url) + self.hide_loading_overlay() + organization_options = self._select_options("id_organization") + self.assertEqual(organization_options, [org1.name]) + self.assertNotIn(org2.name, organization_options) + + with self.subTest("targets of other organizations are not offered"): + self._select2("id_organization", org1.name) + group_options = self._select_options("id_group") + location_options = self._select_options("id_location") + self.assertEqual(group_options, [group1.name]) + self.assertEqual(location_options, [location1.name]) + self.assertNotIn(group2.name, group_options) + self.assertNotIn(location2.name, location_options) + + with self.subTest("the changelist hides other organizations"): + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self.hide_loading_overlay() + labels = [ + row.find_element(By.CSS_SELECTOR, "th.field-label").text + for row in self._rows() + ] + self.assertEqual(labels, [batch1.label]) + self.assertNotIn(batch2.label, labels) + + with self.subTest("a batch of another organization cannot be opened"): + self.open( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch2.pk]) + ) + self._wait_for_url(reverse("admin:index")) + + with self.subTest("only the devices of the managed organization are targeted"): + self._fill_wizard(type="Reboot", label="isolated-reboot", organization=org1) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Targets"], org1.name) + self.assertEqual(summary["Will run on"], "2 devices") + self.assertEqual(self._device_names(), [device.name for device in devices1]) + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("isolated-reboot", "failed", 2) + self.assertEqual( + sorted(self._command_device_names()), + sorted(device.name for device in devices1), + ) + self.assertEqual(self._command_statuses(), ["failed"] * 2) + + with self.subTest("the batch page of the operator is updated live"): + command = Command.objects.filter( + batch_command__label="isolated-reboot" + ).first() + command.status = "success" + command.output = "operator output" + command._save_without_resurrecting() + WebDriverWait(self.web_driver, 10).until( + lambda driver: sorted(self._command_statuses()) + == ["failed", "success"], + message="the command update was never pushed to the page", + ) + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value="#result_list .command-output pre" + ).text, + "operator output", + ) + + with self.subTest("the operator can exclude a device from the run"): + self._fill_wizard(type="Reboot", label="excluded-reboot", organization=org1) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + checkbox = self.find_element( + by=By.CSS_SELECTOR, value="#result_list tbody .device-checkbox" + ) + excluded_pk = checkbox.get_attribute("value") + checkbox.click() + self.assertEqual( + self.find_element(by=By.ID, value="selected-count").text, "1" + ) + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("excluded-reboot", "failed", 1) + self.assertNotIn( + excluded_pk, + [row.get_attribute("data-device-pk") for row in self._rows()], + ) + + with self.subTest("the add permission is checked again on the review page"): + self._fill_wizard(type="Reboot", label="revoked-reboot", organization=org1) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + operator.groups.clear() + self.find_element(by=By.ID, value="execute-button").click() + self.assertEqual( + self.find_element(by=By.TAG_NAME, value="body").text, + "403 Forbidden", + ) + self.assertFalse( + BatchCommand.objects.filter(label="revoked-reboot").exists() + ) + + with self.subTest("the view permission is not enough to execute"): + viewer = self._create_operator( + organizations=[org1], username="viewer", email="viewer@test.com" + ) + viewer.groups.clear() + viewer.user_permissions.set( + Permission.objects.filter(codename="view_batchcommand") + ) + self.web_driver.delete_all_cookies() + self.login(username=viewer.username, password="tester") + self.web_driver.get(f"{self.live_server_url}{self.execute_url}") + self.assertEqual( + self.find_element(by=By.TAG_NAME, value="body").text, + "403 Forbidden", + ) + self.web_driver.get(f"{self.live_server_url}{self.confirm_url}") + self.assertEqual( + self.find_element(by=By.TAG_NAME, value="body").text, + "403 Forbidden", + ) + + def test_batch_command_menu_search_and_filters(self): + """Covers reaching the wizard and the changelist from the menu, and + the searches and filters of both result tables: the batch changelist + (label, status, type, organization, group and location) and the device + table of a batch page. + """ + + def open_menu_item(group_label, item_label): + self.find_element( + by=By.CSS_SELECTOR, value=f'.mg-head[aria-label="{group_label}"]' + ).click() + self.find_element( + by=By.CSS_SELECTOR, + value=f'.menu-group.active a.mg-link[aria-label="{item_label}"]', + timeout=5, + ).click() + + def search(query): + table = self.find_element(by=By.CSS_SELECTOR, value="#result_list") + search_field = self.find_element(by=By.ID, value="searchbar") + search_field.clear() + search_field.send_keys(query) + search_field.submit() + WebDriverWait(self.web_driver, 5).until( + lambda driver: f"q={quote(query)}" in driver.current_url + ) + WebDriverWait(self.web_driver, 5).until(EC.staleness_of(table)) + self.hide_loading_overlay() + + def filter_by_autocomplete(param_name, option): + current_url = self.web_driver.current_url + tables = self.web_driver.find_elements(By.CSS_SELECTOR, "#result_list") + self._open_autocomplete_filter(param_name) + self.find_element( + by=By.XPATH, + value=( + "//li[contains(@class, 'select2-results__option')]" + f"[normalize-space()='{option}']" + ), + ).click() + self.find_element(by=By.ID, value="ow-apply-filter").click() + WebDriverWait(self.web_driver, 5).until( + lambda driver: driver.current_url != current_url + ) + if tables: + WebDriverWait(self.web_driver, 5).until(EC.staleness_of(tables[0])) + self.hide_loading_overlay() + + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + devices = self._create_devices(org1, 50) + searched_device, grouped_device, located_device, *_ = devices + self._create_devices(org2, 1) + group1 = self._create_device_group(name="group1", organization=org1) + location1 = self._create_location(name="location1", organization=org1) + grouped_device.group = group1 + grouped_device.full_clean() + grouped_device.save() + self._create_object_location(content_object=located_device, location=location1) + self._create_batch_command(organization=org2, label="org2-batch") + self._create_batch_command(organization=org1, label="group-batch", group=group1) + self._create_batch_command( + organization=org1, label="location-batch", location=location1 + ) + self.login() + + with self.subTest("the wizard is reachable from the menu and runs"): + self.open(reverse("admin:index")) + open_menu_item("Network Operations", "Run Mass command") + self._wait_for_url(self.execute_url) + + self._fill_wizard( + type="Reboot", + label="menu-reboot", + organization=org1, + open_page=False, + ) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Type"], "Reboot") + self.assertEqual(summary["Label"], "menu-reboot") + self.assertEqual(summary["Targets"], org1.name) + self.assertEqual(summary["Will run on"], "50 devices") + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("menu-reboot", "failed", 20) + affected_devices = self.find_element( + by=By.CSS_SELECTOR, value=".field-affected_devices .readonly" + ) + paginator = self.find_element(by=By.CSS_SELECTOR, value=".paginator") + command_names = self._command_device_names() + self.assertEqual(affected_devices.text, "50") + self.assertEqual(paginator.text, "50 commands") + self.assertEqual(len(command_names), 20) + self.assertEqual( + set(command_names) - {device.name for device in devices}, set() + ) + self.assertEqual(self._command_statuses(), ["failed"] * 20) + + with self.subTest("the changelist is reachable and searchable"): + self.open(reverse("admin:index")) + open_menu_item("Network Operations", "Mass commands") + self._wait_for_url(self.changelist_url) + self.assertEqual( + self._changelist_labels(), + ["menu-reboot", "location-batch", "group-batch", "org2-batch"], + ) + + search("menu-reboot") + self.assertEqual(self._changelist_labels(), ["menu-reboot"]) + search("no-such-batch") + self.assertEqual( + self.find_element(by=By.CSS_SELECTOR, value=".paginator").text, + "0 Mass commands", + ) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + + with self.subTest("the changelist filters narrow the results"): + self._filter_by("status", "failed") + self.assertEqual(self._changelist_labels(), ["menu-reboot"]) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self._filter_by("status", "idle") + self.assertEqual( + set(self._changelist_labels()), + {"org2-batch", "group-batch", "location-batch"}, + ) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self._filter_by("type", "Reboot") + self.assertEqual(self._changelist_labels(), ["menu-reboot"]) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self._filter_by("type", "Custom commands") + self.assertEqual( + set(self._changelist_labels()), + {"org2-batch", "group-batch", "location-batch"}, + ) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + + with self.subTest("the changelist autocomplete filters narrow the results"): + filter_by_autocomplete("organization", org2.name) + self.assertEqual(self._changelist_labels(), ["org2-batch"]) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + filter_by_autocomplete("group_id", group1.name) + self.assertEqual(self._changelist_labels(), ["group-batch"]) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + filter_by_autocomplete("location_id", location1.name) + self.assertEqual(self._changelist_labels(), ["location-batch"]) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + + with self.subTest("the organization filter offers every organization"): + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + organization_options = self._autocomplete_options("organization") + self.assertIn(org1.name, organization_options) + self.assertIn(org2.name, organization_options) + + with self.subTest("the batch is opened from the changelist"): + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self.find_element( + by=By.XPATH, + value="//table[@id='result_list']//a[normalize-space()='menu-reboot']", + ).click() + batch = BatchCommand.objects.get(label="menu-reboot") + self._wait_for_url( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + ) + self.assertEqual(len(self._rows()), 20) + + with self.subTest("the command table is searchable"): + self.open( + reverse( + f"admin:{self.app_label}_batchcommand_change", + args=[BatchCommand.objects.get(label="menu-reboot").pk], + ) + ) + search(searched_device.name) + self.assertEqual(self._command_device_names(), [searched_device.name]) + self.assertEqual(self._command_statuses(), ["failed"]) + search("no-such-device") + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value="#result_list .empty-results" + ).text, + "No commands found.", + ) + + with self.subTest("the command table is filterable"): + self.open( + reverse( + f"admin:{self.app_label}_batchcommand_change", + args=[BatchCommand.objects.get(label="menu-reboot").pk], + ) + ) + self._filter_by("status", "failed") + self.assertEqual(self._command_statuses(), ["failed"] * 20) + self._filter_by("status", "success") + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value="#result_list .empty-results" + ).text, + "No commands found.", + ) + self.open( + reverse( + f"admin:{self.app_label}_batchcommand_change", + args=[BatchCommand.objects.get(label="menu-reboot").pk], + ) + ) + self._filter_by("device group", group1.name) + self.assertEqual(self._command_device_names(), [grouped_device.name]) + self.open( + reverse( + f"admin:{self.app_label}_batchcommand_change", + args=[BatchCommand.objects.get(label="menu-reboot").pk], + ) + ) + self._filter_by("location", location1.name) + self.assertEqual(self._command_device_names(), [located_device.name]) + self.assertEqual( + self.web_driver.find_elements( + By.CSS_SELECTOR, ".ow-filter.organization" + ), + [], + ) + + with self.subTest("the operator only sees the managed organization"): + operator = self._create_operator(organizations=[org1]) + self.web_driver.delete_all_cookies() + self.login(username=operator.username, password="tester") + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self.assertEqual( + self._changelist_labels(), + ["menu-reboot", "location-batch", "group-batch"], + ) + organization_options = self._autocomplete_options("organization") + self.assertIn(org1.name, organization_options) + self.assertNotIn(org2.name, organization_options) + search("menu-reboot") + self.assertEqual(self._changelist_labels(), ["menu-reboot"]) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self._filter_by("status", "failed") + self.assertEqual(self._changelist_labels(), ["menu-reboot"]) + self.open( + reverse( + f"admin:{self.app_label}_batchcommand_change", + args=[BatchCommand.objects.get(label="menu-reboot").pk], + ) + ) + filter_titles = [ + title.text + for title in self.find_elements( + by=By.CSS_SELECTOR, value="#ow-changelist-filter .filter-title h3" + ) + ] + self.assertIn("By status", filter_titles) + self.assertNotIn("By organization", filter_titles) + + def test_recent_commands_show_the_mass_command(self): + org = self._get_org() + devices = self._create_devices(org, 2) + skipped_device = self._create_device( + name="device-skipped", + organization=org, + mac_address="00:11:22:33:44:99", + ) + self.login() + self._fill_wizard( + type="Custom commands", + label="recent-commands", + organization=org, + command_input={"command": "echo test"}, + ) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + self.find_element(by=By.ID, value="execute-button").click() + WebDriverWait(self.web_driver, 30).until( + lambda driver: BatchCommand.objects.filter(label="recent-commands").exists() + ) + batch = BatchCommand.objects.get(label="recent-commands") + batch_url = reverse( + f"admin:{self.app_label}_batchcommand_change", args=[batch.pk] + ) + self._wait_for_url(batch_url, timeout=30) + WebDriverWait(self.web_driver, 10).until( + lambda driver: self._command_statuses() == ["failed", "failed", "skipped"], + message=f"got {self._command_statuses()}", + ) + + with self.subTest("the device of a skipped row is not a link"): + row = self.find_element( + by=By.CSS_SELECTOR, value=f"#batch-command-row-{skipped_device.pk}" + ) + self.assertEqual(row.find_elements(By.CSS_SELECTOR, "a.device-link"), []) + self.assertEqual( + row.find_element(By.CSS_SELECTOR, ".device-name-disabled").text, + skipped_device.name, + ) + + with self.subTest("the device of a command opens its recent commands"): + device = devices[0] + self.find_element( + by=By.CSS_SELECTOR, + value=f"#batch-command-row-{device.pk} a.device-link", + ).click() + self._wait_for_url( + reverse( + f"admin:{self.config_app_label}_device_change", args=[device.pk] + ) + ) + command = Command.objects.get(device=device) + + def field(name): + return self.find_element( + by=By.CSS_SELECTOR, + value=f"#command_set-2-group .field-{name} .readonly", + ).text + + self.assertEqual(field("status_display"), command.get_status_display()) + self.assertEqual(field("type"), command.get_type_display()) + self.assertEqual(field("input_data"), command.input_data) + self.assertEqual(field("output_data"), command.output.strip()) + self.assertEqual(field("batch_command"), batch.label) + self.assertNotEqual(field("created"), "") + self.assertNotEqual(field("modified"), "") + link = self.find_element( + by=By.CSS_SELECTOR, + value="#command_set-2-group .field-batch_command .readonly a", + ) + self.assertEqual(urlparse(link.get_attribute("href")).path, batch_url) + + def test_organization_scoped_custom_command_type(self): + org1 = self._create_org( + name="scoped org", slug="scoped-org", id=UUID(SCOPED_ORGANIZATION_ID) + ) + org2 = self._create_org( + name="org2", slug="org2", id=UUID(DEFAULT_ORGANIZATION_ID) + ) + devices1 = self._create_devices(org1, 2) + self._create_devices(org2, 1) + self._create_batch_command(organization=org2, label="org2-batch") + operator1 = self._create_operator( + organizations=[org1], username="operator1", email="operator1@test.com" + ) + operator2 = self._create_operator( + organizations=[org2], username="operator2", email="operator2@test.com" + ) + default_types = ["Custom commands", "Reboot", "Change password"] + + with self.subTest("the scoped organization is offered the custom type"): + self.web_driver.delete_all_cookies() + self.login(username=operator1.username, password="tester") + self.open(self.execute_url) + self._wait_for_url(self.execute_url) + self.hide_loading_overlay() + self.assertEqual( + self._select_options("id_type"), default_types + ["UCI show"] + ) + + with self.subTest("the custom type renders the input of its schema"): + self._fill_wizard( + type="UCI show", + label="uci-show", + organization=org1, + command_input={"config": "network"}, + ) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Type"], "UCI show") + self.assertEqual(summary["Command"], "config: network") + self.assertEqual(summary["Will run on"], "2 devices") + self.assertEqual(self._device_names(), [device.name for device in devices1]) + self.find_element(by=By.ID, value="execute-button").click() + batch = BatchCommand.objects.get(label="uci-show") + self._wait_for_url( + reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + ) + self.assertEqual(batch.type, "uci_show") + self.assertEqual(batch.input, {"config": "network"}) + + with self.subTest("the changelist type filter offers the custom type"): + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self.assertIn("UCI show", self._filter_options("type")) + self._filter_by("type", "UCI show") + self.assertEqual(self._changelist_labels(), ["uci-show"]) + + with self.subTest("another organization only sees the default types"): + self.web_driver.delete_all_cookies() + self.login(username=operator2.username, password="tester") + self.open(self.execute_url) + self._wait_for_url(self.execute_url) + self.hide_loading_overlay() + self.assertEqual(self._select_options("id_type"), default_types) + self.open(self.changelist_url) + self._wait_for_url(self.changelist_url) + self.assertEqual(self._changelist_labels(), ["org2-batch"]) + self.assertNotIn("UCI show", self._filter_options("type")) diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py index e88109ebe..5ec498c64 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -1,7 +1,5 @@ import json import uuid -from contextlib import redirect_stderr -from io import StringIO from unittest import mock from celery.exceptions import SoftTimeLimitExceeded @@ -9,6 +7,8 @@ from django.test import TestCase, TransactionTestCase from swapper import load_model +from openwisp_utils.tests import capture_stderr + from ...config.tests.test_controller import TestRegistrationMixin from .. import tasks from ..connectors.exceptions import CommandTimeoutException @@ -165,9 +165,10 @@ def test_launch_command_ssh_timeout(self, *args): "The command took longer than expected: connection timed out after 30s\n", ) + @capture_stderr() @mock.patch(_mock_execute, side_effect=RuntimeError("test error")) @mock.patch(_mock_connect, return_value=True) - def test_launch_command_exception(self, *args): + def test_launch_command_exception(self, stderr, *args): dc = self._create_device_connection() command = Command( device=dc.device, @@ -178,14 +179,37 @@ def test_launch_command_exception(self, *args): command.full_clean() command.save() # must call this explicitly because lack of transactions in this test case - with redirect_stderr(StringIO()) as stderr: - tasks.launch_command.delay(command.pk) - expected = f"An exception was raised while executing command {command.pk}" - self.assertIn(expected, stderr.getvalue()) + tasks.launch_command.delay(command.pk) + expected = f"An exception was raised while executing command {command.pk}" + self.assertIn(expected, stderr.getvalue()) command.refresh_from_db() self.assertEqual(command.status, "failed") self.assertEqual(command.output, "Internal system error: test error\n") + @capture_stderr() + def test_launch_command_failure_cleans_change_password_input(self, stderr): + dc = self._create_device_connection() + password = "SuperSecret123" + errors = ( + SoftTimeLimitExceeded(), + CommandTimeoutException("connection timed out after 30s"), + RuntimeError("test error"), + ) + for error in errors: + with self.subTest(type(error).__name__): + command = Command( + device=dc.device, + connection=dc, + type="change_password", + input={"password": password, "confirm_password": password}, + ) + command.full_clean() + command.save() + with mock.patch.object(Command, "execute", side_effect=error): + tasks.launch_command(command.pk) + command.refresh_from_db() + self.assertNotIn(password, json.dumps(command.input)) + @mock.patch( "openwisp_controller.connection.base.models.AbstractCommand._exec_command" ) @@ -405,12 +429,13 @@ def test_launch_batch_command_timeout(self, mocked_create_commands): batch.refresh_from_db() self.assertEqual(batch.status, "failed") + @capture_stderr() @mock.patch( "openwisp_controller.connection.base.models.AbstractBatchCommand" ".create_commands", side_effect=RuntimeError("test error"), ) - def test_launch_batch_command_exception(self, mocked_create_commands): + def test_launch_batch_command_exception(self, stderr, mocked_create_commands): org = self._get_org() device = self._create_device(organization=org) self._create_config(device=device) @@ -424,12 +449,11 @@ def test_launch_batch_command_exception(self, mocked_create_commands): ) batch.full_clean() batch.save() - with redirect_stderr(StringIO()) as stderr: - tasks.launch_batch_command(batch_id=batch.pk) - self.assertIn( - f"An exception was raised while executing batch command {batch.pk}", - stderr.getvalue(), - ) + tasks.launch_batch_command(batch_id=batch.pk) + self.assertIn( + f"An exception was raised while executing batch command {batch.pk}", + stderr.getvalue(), + ) batch.refresh_from_db() self.assertEqual(batch.status, "failed") @@ -444,12 +468,17 @@ def test_launch_batch_command_exception(self, mocked_create_commands): batch.full_clean() batch.save() tasks.launch_batch_command(batch_id=batch.pk) + self.assertIn( + f"An exception was raised while executing batch command {batch.pk}", + stderr.getvalue(), + ) batch.refresh_from_db() self.assertEqual(batch.status, "failed") self.assertNotIn(password, json.dumps(batch.input)) + @capture_stderr() @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") - def test_launch_batch_command_all_devices_skipped(self, mocked_delay): + def test_launch_batch_command_all_devices_skipped(self, stderr, mocked_delay): org = self._get_org() device = self._create_device(organization=org) self._create_config(device=device) @@ -470,6 +499,7 @@ def test_launch_batch_command_all_devices_skipped(self, mocked_delay): self.assertIn(str(device.pk), batch.skipped_devices) self.assertEqual(batch.status, "failed") mocked_delay.assert_not_called() + self.assertIn("Skipping device", stderr.getvalue()) @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") def test_launch_batch_command_already_processed(self, mocked_delay): diff --git a/openwisp_controller/connection/tests/utils.py b/openwisp_controller/connection/tests/utils.py index 1574e2364..9be852d4e 100644 --- a/openwisp_controller/connection/tests/utils.py +++ b/openwisp_controller/connection/tests/utils.py @@ -1,12 +1,15 @@ import os +from django.contrib.messages import get_messages from mockssh import Server from swapper import load_model from openwisp_users.tests.utils import TestOrganizationMixin from ...config.tests.utils import CreateConfigTemplateMixin +from ...tests.utils import TestAdminMixin from .. import settings as app_settings +from ..admin import BatchCommandAdmin Credentials = load_model("connection", "Credentials") DeviceConnection = load_model("connection", "DeviceConnection") @@ -151,6 +154,34 @@ def _create_command(self, device_conn=None, device_conn_opts={}, **kwargs): return Command.objects.create(**opts) +class BatchCommandMixin(TestAdminMixin, CreateConnectionsMixin): + def _post_execute(self, **overrides): + data = { + "type": "custom", + "input": '{"command": "echo test"}', + "label": "test-label", + "notes": "", + "organization": "", + "group": "", + "location": "", + } + data.update(overrides) + return self.client.post(self.execute_url, data) + + def _start_wizard(self, **overrides): + response = self._post_execute(**overrides) + assert response.status_code == 302, response.context["form"].errors + return self.client.session[BatchCommandAdmin.session_key] + + def _post_confirm(self, token, excluded=""): + return self.client.post( + self.confirm_url, {"token": token, "excluded": excluded} + ) + + def _messages(self, response): + return [str(message) for message in get_messages(response.wsgi_request)] + + def _ping_command_callable(destination_address, interface_name=None): command = f"ping -c 4 {destination_address}" if interface_name: @@ -160,3 +191,7 @@ def _ping_command_callable(destination_address, interface_name=None): def _restart_network_command_callable(): return "/etc/init.d/networking restart" + + +def _uci_show_command_callable(config): + return f"uci show {config}" diff --git a/openwisp_controller/connection/utils.py b/openwisp_controller/connection/utils.py index 905afa7a8..9be47ff6f 100644 --- a/openwisp_controller/connection/utils.py +++ b/openwisp_controller/connection/utils.py @@ -1,6 +1,15 @@ +from django.utils import formats, timezone from openwisp_notifications.utils import _get_object_link +def format_localized_datetime(value): + if not value: + return "" + if timezone.is_aware(value): + value = timezone.localtime(value) + return formats.date_format(value, "DATETIME_FORMAT") + + def get_connection_working_notification_target_url(obj, field, absolute_url=True): url = _get_object_link(obj._related_object(field), absolute_url) return f"{url}#deviceconnection_set-group" diff --git a/openwisp_controller/connection/widgets.py b/openwisp_controller/connection/widgets.py index 7160aa599..56bd7302e 100644 --- a/openwisp_controller/connection/widgets.py +++ b/openwisp_controller/connection/widgets.py @@ -5,6 +5,7 @@ Credentials = swapper.load_model("connection", "Credentials") Command = swapper.load_model("connection", "Command") +BatchCommand = swapper.load_model("connection", "BatchCommand") app_label = Credentials._meta.app_label model_name = Credentials._meta.model_name @@ -46,3 +47,37 @@ def media(self): css = {"all": ["connection/css/command-inline.css"]} media = forms.Media(js=js, css=css) return super().media + media + + +class BatchCommandSchemaWidget(CommandSchemaWidget): + schema_view_name = ( + f"admin:{BatchCommand._meta.app_label}" + f"_{BatchCommand._meta.model_name}_schema" + ) + app_label_model = f"{BatchCommand._meta.app_label}_{BatchCommand._meta.model_name}" + extra_attrs = { + "data-schema-selector": "#id_type", + "data-show-errors": "never", + "data-options": '{"disable_properties": true}', + } + + @property + def media(self): + return super(CommandSchemaWidget, self).media + + +class OrganizationScopedSelect(forms.Select): + """Select which marks each option with the organization it belongs to. + + Used for the device group and location of the mass command wizard: every + option gets a "data-organization-id" attribute, so that when an + organization is chosen the page can hide the groups and locations of the + other organizations without asking the server again. + """ + + def create_option(self, name, value, *args, **kwargs): + option = super().create_option(name, value, *args, **kwargs) + instance = getattr(value, "instance", None) + if instance is not None: + option["attrs"]["data-organization-id"] = str(instance.organization_id) + return option diff --git a/openwisp_controller/geo/estimated_location/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py index beaf3f304..d1ba1330b 100644 --- a/openwisp_controller/geo/estimated_location/tests/tests.py +++ b/openwisp_controller/geo/estimated_location/tests/tests.py @@ -734,9 +734,10 @@ def _verify_location_details(device, mocked_response): old_location = device2.devicelocation.location device2.last_ip = "172.217.22.10" device2.save() - # 3 queries related to notifications cleanup + # 3 queries related to notifications cleanup, + # 1 to set BatchCommand.location to NULL when the location is deleted device2.refresh_from_db() - with self.assertNumQueries(16): + with self.assertNumQueries(17): manage_estimated_locations(device2.pk, device2.last_ip) mock_info.assert_called_once_with( f"Estimated location saved successfully for {device2.pk}" diff --git a/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py b/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py index c0f67afd1..2bca5329c 100644 --- a/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py +++ b/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py @@ -2,7 +2,6 @@ import uuid -import django import django.core.serializers.json import django.db.migrations.operations.special import django.db.models.deletion @@ -71,11 +70,7 @@ class Migration(migrations.Migration): "type", models.CharField( max_length=16, - choices=( - connection_config.commands.COMMAND_CHOICES - if django.VERSION < (5, 0) - else connection_config.commands.get_command_choices - ), + choices=connection_config.commands.get_command_choices, ), ), ( @@ -98,10 +93,11 @@ class Migration(migrations.Migration): blank=True, null=True, default=dict, - verbose_name="Skipped devices", + verbose_name="skipped devices", help_text=( - "Maps device UUIDs to validation error messages for " - "devices that were skipped during command creation." + "Maps device UUIDs to the name of the device and the " + "validation error that caused it to be skipped during " + "command creation." ), ), ), diff --git a/tests/openwisp2/sample_connection/pytest.py b/tests/openwisp2/sample_connection/pytest.py index 910268306..f5607be5f 100644 --- a/tests/openwisp2/sample_connection/pytest.py +++ b/tests/openwisp2/sample_connection/pytest.py @@ -1,3 +1,6 @@ +from openwisp_controller.connection.tests.pytest import ( + TestBatchCommandConsumer as BaseTestBatchCommandConsumer, +) from openwisp_controller.connection.tests.pytest import ( TestCommandsConsumer as BaseTestCommandsConsumer, ) @@ -7,4 +10,9 @@ class TestCommandsConsumer(BaseTestCommandsConsumer): pass +class TestBatchCommandConsumer(BaseTestBatchCommandConsumer): + app_label = "sample_connection" + + +del BaseTestBatchCommandConsumer del BaseTestCommandsConsumer diff --git a/tests/openwisp2/sample_connection/tests.py b/tests/openwisp2/sample_connection/tests.py index cc225d2ab..b93bcff70 100644 --- a/tests/openwisp2/sample_connection/tests.py +++ b/tests/openwisp2/sample_connection/tests.py @@ -1,9 +1,18 @@ +from openwisp_controller.connection.tests.test_admin import ( + TestBatchCommandAdmin as BaseTestBatchCommandAdmin, +) from openwisp_controller.connection.tests.test_admin import ( TestCommandInlines as BaseTestCommandInlines, ) from openwisp_controller.connection.tests.test_admin import ( TestConnectionAdmin as BaseTestConnectionAdmin, ) +from openwisp_controller.connection.tests.test_api import ( + TestBatchCommandsAPI as BaseTestBatchCommandsAPI, +) +from openwisp_controller.connection.tests.test_api import ( + TestBatchCommandsAPITransaction as BaseTestBatchCommandsAPITransaction, +) from openwisp_controller.connection.tests.test_api import ( TestConnectionApi as BaseTestConnectionApi, ) @@ -32,6 +41,10 @@ class TestCommandInlines(BaseTestCommandInlines): config_app_label = "sample_config" +class TestBatchCommandAdmin(BaseTestBatchCommandAdmin): + app_label = "sample_connection" + + class TestModels(BaseTestModels): app_label = "sample_connection" @@ -60,6 +73,15 @@ class TestConnectionApi(BaseTestConnectionApi): pass +class TestBatchCommandsAPI(BaseTestBatchCommandsAPI): + pass + + +class TestBatchCommandsAPITransaction(BaseTestBatchCommandsAPITransaction): + pass + + +del BaseTestBatchCommandAdmin del BaseTestCommandInlines del BaseTestConnectionAdmin del BaseTestModels @@ -69,3 +91,5 @@ class TestConnectionApi(BaseTestConnectionApi): del BaseTestNotifications del BaseTestNotificationTransaction del BaseTestConnectionApi +del BaseTestBatchCommandsAPI +del BaseTestBatchCommandsAPITransaction From 07386f1296444993c8109ced65426cd5ec6d17ad Mon Sep 17 00:00:00 2001 From: Deepanshu Sahu Date: Thu, 17 Sep 2026 22:26:15 +0530 Subject: [PATCH 4/9] [feature] Added mass command admin action to device list page #1347 Added an Execute mass command action to the device changelist admin page, allowing administrators to select specific devices and run mass commands directly. Closes #1347 --- docs/user/shell-commands.rst | 20 ++ openwisp_controller/connection/admin.py | 184 +++++++++++-- .../static/connection/js/execute-command.js | 7 +- .../batch_command_change_form.html | 2 +- .../batch_command/execute_command.html | 11 +- .../connection/tests/test_admin.py | 260 ++++++++++++++++++ .../connection/tests/test_selenium.py | 247 ++++++++++++++++- openwisp_controller/connection/tests/utils.py | 24 ++ tests/openwisp2/sample_connection/tests.py | 1 + 9 files changed, 725 insertions(+), 31 deletions(-) diff --git a/docs/user/shell-commands.rst b/docs/user/shell-commands.rst index 810c8ae71..c154fdba7 100644 --- a/docs/user/shell-commands.rst +++ b/docs/user/shell-commands.rst @@ -215,6 +215,26 @@ devices of the system. Other users must choose at least one target, and only see the command types enabled for their organizations (see :ref:`openwisp_controller_organization_enabled_commands`). +Sending a Mass Command to Selected Devices +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A mass command can also be started from the device list: select the +devices with their checkboxes, choose *Execute mass command* from the +actions dropdown and click *Go*. + +The first step opens with the selection already applied: a message at the +top of the page states how many devices the command will run on, and the +organization is filled in and cannot be changed, while device group and +location are not asked for, since the devices are already known. + +The selected devices must belong to the same organization, otherwise the +action refuses to start. The exception is a superuser selecting every +device of the system: the command then runs on all of them and no target +is asked for. + +The rest of the workflow is the same as described below: the devices can +still be reviewed and left out before executing. + Reviewing the Devices ~~~~~~~~~~~~~~~~~~~~~ diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py index 2e299dce6..008190e4d 100644 --- a/openwisp_controller/connection/admin.py +++ b/openwisp_controller/connection/admin.py @@ -12,7 +12,12 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Count -from django.http import HttpResponseForbidden, HttpResponseNotAllowed, JsonResponse +from django.http import ( + HttpResponseForbidden, + HttpResponseNotAllowed, + HttpResponseRedirect, + JsonResponse, +) from django.shortcuts import redirect from django.template.response import TemplateResponse from django.urls import path, resolve @@ -20,6 +25,7 @@ from django.utils.safestring import mark_safe from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext from openwisp_users.multitenancy import MultitenantOrgFilter from openwisp_utils.admin import ReadOnlyAdmin, TimeReadonlyAdminMixin @@ -63,6 +69,7 @@ class Meta: class BatchCommandExecutionForm(forms.ModelForm): required_css_class = "required" + devices = forms.CharField(widget=forms.HiddenInput, required=False) class Meta: model = BatchCommand @@ -97,9 +104,18 @@ class Media: ] } - def __init__(self, *args, request=None, **kwargs): + def __init__( + self, *args, request=None, device_ids=None, system_wide=False, **kwargs + ): super().__init__(*args, **kwargs) self.request = request + self.system_wide = system_wide + self.device_ids = self._scope_devices(device_ids) + if self.device_ids: + self.fields["devices"].initial = ",".join(self.device_ids) + for field_name in ("organization", "group", "location"): + self.fields[field_name].disabled = True + self.fields["organization"].initial = self._selected_organization_id() if request is None or request.user.is_superuser: # without a request there is no user to restrict the fields to, # while superusers are allowed to use every organization, group, @@ -127,8 +143,51 @@ def __init__(self, *args, request=None, **kwargs): ] self.fields["type"].choices = empty_choices + list(allowed_commands.items()) + def _scope_devices(self, device_ids): + """Keeps only the selected devices that exist and that the user can use. + + Returns the ids which are left. It also notes which organizations + those devices belong to, and whether any selected device was dropped + because it was deleted or the user does not manage its organization, + so that clean() can reject the form in those cases. + """ + self._organization_ids = set() + self._dropped_devices = False + if not device_ids: + return [] + devices = Device.objects.filter(pk__in=device_ids) + if self.request is not None and not self.request.user.is_superuser: + devices = devices.filter( + organization_id__in=self.request.user.organizations_managed + ) + rows = list(devices.values_list("pk", "organization_id")) + self._organization_ids = {row[1] for row in rows} + self._dropped_devices = len(rows) != len(set(device_ids)) + return [str(row[0]) for row in rows] + + def _selected_organization_id(self): + """Returns the organization of the selected devices, or None. + + When devices are picked from the device list, the organization field + is locked and filled in with their organization, so the user can see + where the command will run. This works only when all the devices + belong to one organization: if they belong to several, None is + returned and clean() rejects the form. + """ + if len(self._organization_ids) != 1: + return None + return str(next(iter(self._organization_ids))) + def clean(self): cleaned_data = super().clean() + if self._dropped_devices: + raise ValidationError( + _("Some of the selected devices are no longer available.") + ) + if len(self._organization_ids) > 1: + raise ValidationError( + _("All devices must belong to the same organization.") + ) if self.request is None or self.request.user.is_superuser: return cleaned_data organization = cleaned_data.get("organization") @@ -136,7 +195,7 @@ def clean(self): location = cleaned_data.get("location") # a batch without any target would run on every device of the # deployment, which only superusers are allowed to do - if not any([organization, group, location]): + if not self.device_ids and not any([organization, group, location]): raise ValidationError( _( "Please select at least one of: organization, device group," @@ -152,10 +211,12 @@ def fieldsets(self): fields: the sections are listed here, in the order they are shown, and the template renders each field with the admin markup. """ - sections = ( - (_("Command"), ("type", "input", "label", "notes")), - (_("Targets"), ("organization", "location", "group")), - ) + sections = [(_("Command"), ("type", "input", "label", "notes"))] + if not self.system_wide: + targets = ("organization",) + if not self.device_ids: + targets += ("location", "group") + sections.append((_("Targets"), targets)) return [ (title, [self[field_name] for field_name in field_names]) for title, field_names in sections @@ -187,6 +248,7 @@ def _pk(value): "organization_id": _pk(self.cleaned_data.get("organization")), "group_id": _pk(self.cleaned_data.get("group")), "location_id": _pk(self.cleaned_data.get("location")), + "device_ids": self.device_ids, } @@ -547,7 +609,11 @@ def execute_command_view(self, request): return HttpResponseNotAllowed(["GET", "POST"]) self._check_add_permission(request) if request.method == "POST": - form = BatchCommandExecutionForm(request.POST, request=request) + form = BatchCommandExecutionForm( + request.POST, + request=request, + device_ids=self._get_pk_list(request.POST, "devices"), + ) if form.is_valid(): # the POST on the confirm page requires the data of this step, # hidden inputs cannot carry it because changing the page of @@ -561,12 +627,30 @@ def execute_command_view(self, request): wizard = request.session.get(self.session_key) if request.GET.get("back") and wizard: form = BatchCommandExecutionForm( - initial=self._wizard_initial(wizard), request=request + initial=self._wizard_initial(wizard), + request=request, + device_ids=wizard.get("device_ids"), ) else: # the wizard is left in the session: another tab may be # reviewing it, and opening this page must not discard it form = BatchCommandExecutionForm(request=request) + return self._render_execute_page(request, form) + + def _render_execute_page(self, request, form): + device_count = len(form.device_ids) + if device_count: + messages.warning( + request, + ngettext( + "The command will run on the device you selected.", + "The command will run on the %(count)d devices you selected.", + device_count, + ) + % {"count": device_count}, + ) + elif form.system_wide: + messages.warning(request, _("The command will run on all devices.")) context = { **self.admin_site.each_context(request), "title": _("Execute mass command"), @@ -574,6 +658,7 @@ def execute_command_view(self, request): "form": form, "media": form.media, "has_view_permission": self.has_view_permission(request), + "device_count": device_count, } return TemplateResponse(request, self.execute_command_template, context) @@ -647,22 +732,26 @@ def _resolve_target_queryset(self, request, wizard): page and the execution cannot drift apart; the multitenancy scope is an admin concern, applied on top. """ - try: - devices = BatchCommand.dry_run( - organization_id=wizard.get("organization_id"), - group_id=wizard.get("group_id"), - location_id=wizard.get("location_id"), - )["devices"] - except (ObjectDoesNotExist, ValidationError) as error: - logger.warning( - "Failed to resolve devices for mass command wizard" - " (organization_id=%s, group_id=%s, location_id=%s): %s", - wizard.get("organization_id"), - wizard.get("group_id"), - wizard.get("location_id"), - error, - ) - return Device.objects.none() + device_ids = wizard.get("device_ids") + if device_ids: + devices = Device.objects.filter(pk__in=device_ids) + else: + try: + devices = BatchCommand.dry_run( + organization_id=wizard.get("organization_id"), + group_id=wizard.get("group_id"), + location_id=wizard.get("location_id"), + )["devices"] + except (ObjectDoesNotExist, ValidationError) as error: + logger.warning( + "Failed to resolve devices for mass command wizard" + " (organization_id=%s, group_id=%s, location_id=%s): %s", + wizard.get("organization_id"), + wizard.get("group_id"), + wizard.get("location_id"), + error, + ) + return Device.objects.none() if not request.user.is_superuser: devices = devices.filter( organization_id__in=request.user.organizations_managed @@ -694,6 +783,7 @@ def _devices_digest(self, device_ids): return hashlib.sha256(",".join(pks).encode()).hexdigest() def _confirm_context(self, request, wizard, devices): + device_count = devices.count() targets = [] for model, key in ( (Organization, "organization_id"), @@ -714,11 +804,18 @@ def _confirm_context(self, request, wizard, devices): "wizard": wizard, "command_type_display": command_types.get(wizard["type"], wizard["type"]), "command_description": self._describe_input(wizard.get("input")), - "targets_display": ", ".join(targets) if targets else _("All devices"), - "device_count": devices.count(), + "targets_display": self._targets_display(wizard, targets, device_count), + "device_count": device_count, "has_view_permission": self.has_view_permission(request), } + def _targets_display(self, wizard, targets, device_count): + if wizard.get("device_ids"): + return ngettext( + "%(count)d selected device", "%(count)d selected devices", device_count + ) % {"count": device_count} + return ", ".join(targets) if targets else _("All devices") + def _describe_input(self, command_input): """Renders the submitted input for the review step, so that every registered command type is shown and not only custom ones. @@ -1124,5 +1221,38 @@ def change_view(self, request, object_id, form_url="", extra_context=None): ) return super().change_view(request, object_id, extra_context=extra_context) + @staticmethod + @admin.action(description=_("Execute mass command"), permissions=["change"]) + def execute_mass_command_admin_action(modeladmin, request, queryset): + """Second entry point of the mass command workflow: the devices are + picked one by one instead of being matched by organization, group or + location. The selection travels in the form rather than in the session, + so it cannot outlive the wizard it belongs to. + """ + batch_admin = modeladmin.admin_site.get_model_admin(BatchCommand) + batch_admin._check_add_permission(request) + organizations = queryset.order_by().values_list("organization_id", flat=True) + if organizations.distinct().count() > 1: + if ( + request.user.is_superuser + and not Device.objects.exclude(pk__in=queryset.values("pk")).exists() + ): + return batch_admin._render_execute_page( + request, + BatchCommandExecutionForm(request=request, system_wide=True), + ) + modeladmin.message_user( + request, + _("All devices must belong to the same organization."), + messages.ERROR, + ) + return HttpResponseRedirect(request.get_full_path()) + form = BatchCommandExecutionForm( + request=request, + device_ids=[str(pk) for pk in queryset.values_list("pk", flat=True)], + ) + return batch_admin._render_execute_page(request, form) + admin.site.register(BatchCommand, BatchCommandAdmin) +DeviceAdmin.actions += [BatchCommandAdmin.execute_mass_command_admin_action] diff --git a/openwisp_controller/connection/static/connection/js/execute-command.js b/openwisp_controller/connection/static/connection/js/execute-command.js index bc5863766..12ed0368e 100644 --- a/openwisp_controller/connection/static/connection/js/execute-command.js +++ b/openwisp_controller/connection/static/connection/js/execute-command.js @@ -98,7 +98,12 @@ function initCommandInput($, $typeSelect) { function initOrganizationScope($) { const $organization = $("#id_organization"); - const fields = ["#id_group", "#id_location"]; + const fields = ["#id_group", "#id_location"].filter(function (selector) { + return $(selector).length; + }); + if (!fields.length) { + return; + } fields.forEach(function (selector) { $(selector).data("allOptions", $(selector).find("option").clone()); }); 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 8f86a4ceb..d6ea73d41 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 @@ -102,7 +102,7 @@

{# Allows screen readers to announce live batch progress without reading the whole result table. #} -
+
- + {% comment %} + this page can be opened in two ways: from the menu, or from the "Execute mass + command" action of the device list. The action shows this page without a + redirect, so the URL in the browser stays on the device list. A form without + an "action" attribute posts to the URL in the browser, which would send the + data to the device list. Setting "action" makes the form always post to the + execute page. + {% endcomment %} + {% csrf_token %} + {{ form.devices }}