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 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..24b5095df 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -70,6 +70,9 @@ e.g.: - Sending configuration updates. - :doc:`Executing shell commands `. +- :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 c9702920d..a5f3e0b0c 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,130 @@ 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 paginated 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; all devices must belong to the same + organization) +``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; all devices must belong to + the same organization) +``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 Mass Command Results +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + GET /api/v1/controller/batch-command/{id}/command/ + +Returns the paginated list of commands the mass command created, with the +status and output of each device. The commands of a device which was moved +to another organization are only returned to superusers. + List Device Groups ~~~~~~~~~~~~~~~~~~ @@ -603,7 +727,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 +759,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 +810,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 +840,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 +1031,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..31a036809 100644 --- a/docs/user/shell-commands.rst +++ b/docs/user/shell-commands.rst @@ -5,6 +5,23 @@ Sending Commands to Devices :depth: 3 :local: +Single Device Commands +---------------------- + +Commands can be sent to one device at a time from the device detail page, +as shown in the GIF below: + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/single_command_demo.gif + :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/single_command_demo.gif + :alt: Sending a single command + +Click the **Send Command** button, select the command type and fill in the +required inputs. The command is sent to the device and its status is shown +in the **Recent Commands** section of the device detail page. + +To send the same command to many devices at once, refer to +:ref:`mass_commands`. + Default Commands ---------------- @@ -41,6 +58,76 @@ shell commands, you can add new commands by following instructions in the You can also use the :ref:`REST API ` to execute commands on a device. +.. _mass_commands: + +Mass Commands +------------- + +Mass commands run the same command on many devices at once, for example to +reboot all the devices of a organization, as shown in the GIF below: + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/mass-command-tutorial/mass_command_demo.gif + :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/mass-command-tutorial/mass_command_demo.gif + :alt: Sending a mass command + +Sending a Mass Command +~~~~~~~~~~~~~~~~~~~~~~ + +1. Open *Network Operations* > *Run Mass command*. +2. Choose the command, a **label** to recognize it later, and the + **targets**: organization, device group and location. Combining targets + narrows the selection. +3. Review the matched devices, uncheck any you want to leave out, and + click **Execute**. + +Superusers can leave every target empty to run the command on all the +devices, as shown in the GIF below: + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/mass-command-tutorial/mass_command_superuser.gif + :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/mass-command-tutorial/mass_command_superuser.gif + :alt: Running a mass command on all devices as a superuser + +Other users must choose at least one target and can only use the commands +enabled for their organization (see +:ref:`openwisp_controller_organization_enabled_commands`). + +From the Device List +~~~~~~~~~~~~~~~~~~~~ + +A mass command can also be started from the device list, as shown in the +GIF below: + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/mass-command-tutorial/mass_command_action.gif + :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/mass-command-tutorial/mass_command_action.gif + :alt: Sending a mass command to selected devices + +Select the devices, choose *Execute mass command* from the actions +dropdown and click **Go**. Then choose the command and review the devices +as described above. The selected devices must belong to the same +organization. + +Following the Results +~~~~~~~~~~~~~~~~~~~~~ + +After executing, the mass command page shows the status and output of each +device, updated in real time, as shown in the GIF below: + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/mass-command-tutorial/mass_command_results.gif + :target: https://github.com/openwisp/openwisp-controller/tree/docs/docs/mass-command-tutorial/mass_command_results.gif + :alt: Following the results of a mass command + +- Devices which cannot run the command, for example because they have no + access credentials, are marked as *skipped* together with the reason. +- Past mass commands are listed in *Network Operations* > *Mass commands*. +- Commands sent by a mass command also appear in the **Recent Commands** + of each device, with a link back to the mass command. + +REST API +~~~~~~~~ + +Mass commands can also be sent and followed through the :ref:`Batch +Command API `. + .. _defining_new_menu_options: Defining New Options in the Commands Menu 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..9beafae66 100644 --- a/openwisp_controller/connection/admin.py +++ b/openwisp_controller/connection/admin.py @@ -1,26 +1,59 @@ +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.urls import path, resolve -from django.utils.html import format_html +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, + HttpResponseRedirect, + JsonResponse, +) +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import path, resolve, reverse +from django.utils.html import format_html, format_html_join +from django.utils.http import urlencode +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 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 +68,198 @@ class Meta: widgets = {"input": CommandSchemaWidget} +class BatchCommandExecutionForm(forms.ModelForm): + required_css_class = "required" + devices = forms.CharField(widget=forms.HiddenInput, required=False) + + 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, 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, + # 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 _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") + 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 self.device_ids and 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"))] + 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 + ] + + 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 { + # Identifies this wizard among the ones the session keeps, so + # that a tab executes the mass command it reviewed even when + # another tab started a different one in the meantime. It is not + # the token below, which is reissued when the targeted devices + # change: the key of a wizard has to stay the same for its whole + # life. + "id": uuid4().hex, + # 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")), + "device_ids": self.device_ids, + } + + @admin.register(Credentials) class CredentialsAdmin(MultitenantAdminMixin, TimeReadonlyAdminMixin, admin.ModelAdmin): list_display = ( @@ -108,6 +333,7 @@ class CommandInline(admin.StackedInline): "type", "input_data", "output_data", + "batch_command", "created", "modified", ] @@ -116,6 +342,7 @@ class CommandInline(admin.StackedInline): "type", "input_data", "output_data", + "batch_command", "created", "modified", ] @@ -134,7 +361,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 +442,870 @@ 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_wizards" + wizard_claim_timeout = 60 + max_wizards = 5 + 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, + 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 + # the paginated device table refreshes the page and loses it, + # so it is stored in the session + wizard = form.to_session() + self._save_wizard(request, wizard) + return self._redirect_to_confirm(wizard) + else: + wizard = self._get_wizard(request, request.GET.get("wizard")) + if request.GET.get("back") and wizard: + form = BatchCommandExecutionForm( + 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"), + "opts": self.opts, + "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) + + def confirm_command_view(self, request, wizard_id): + """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_id.hex) + wizard = self._get_wizard(request, wizard_id.hex) + 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 + self._save_wizard(request, 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. + """ + 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 + ) + return devices.distinct() + + def _get_wizards(self, request): + return request.session.get(self.session_key) or {} + + def _get_wizard(self, request, wizard_id): + """Returns the wizard of the given id. + + The session is shared by every browser tab, so the wizards are kept + in a dictionary and the pages of a wizard carry its id: each tab + gets its own wizard even when another one was started after it. + """ + return self._get_wizards(request).get(wizard_id) + + def _save_wizard(self, request, wizard): + """Stores a wizard, dropping the least recently saved ones. + + Every review page which is abandoned leaves its wizard behind, + so only the last "max_wizards" are kept. + """ + wizards = self._get_wizards(request) + wizards.pop(wizard["id"], None) + wizards[wizard["id"]] = wizard + for stale in list(wizards)[: -self.max_wizards]: + del wizards[stale] + request.session[self.session_key] = wizards + + def _discard_wizard(self, request, wizard): + wizards = self._get_wizards(request) + wizards.pop(wizard["id"], None) + request.session[self.session_key] = wizards + + def _redirect_to_confirm(self, wizard): + return redirect( + f"admin:{self.opts.app_label}_{self.opts.model_name}_confirm", + wizard["id"], + ) + + 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): + device_count = devices.count() + 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, + "back_url": "{}?{}".format( + reverse(f"admin:{self.opts.app_label}_{self.opts.model_name}_execute"), + urlencode({"back": 1, "wizard": wizard["id"]}), + ), + "command_type_display": command_types.get(wizard["type"], wizard["type"]), + "command_description": self._describe_input(wizard.get("input")), + "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. + """ + 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, wizard_id): + """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: + + - the wizard of the page is gone, because it already ran or newer + wizards replaced it: go back to the first step and say why + - the token does not match, because the page was rendered before + the targeted devices changed: 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 = self._get_wizard(request, wizard_id) + if not wizard: + # the wizard of the page which was submitted is gone: it already + # ran, or too many other wizards were started after it + return self._restart( + request, + _( + "This mass command is not available anymore, so it was not" + " executed. Please fill in the details again." + ), + ) + 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) + self._discard_wizard(request, wizard) + 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) + self._save_wizard(request, wizard) + self.message_user( + request, + _("The targeted devices changed, please review them again."), + messages.WARNING, + ) + return self._redirect_to_confirm(wizard) + # 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) + self._save_wizard(request, wizard) + self.message_user(request, error.messages[0], messages.ERROR) + return self._redirect_to_confirm(wizard) + 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) + + @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/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..2b874681a 100644 --- a/openwisp_controller/connection/api/urls.py +++ b/openwisp_controller/connection/api/urls.py @@ -45,6 +45,26 @@ def get_view(name): get_view("deviceconnection_detail_view"), name="deviceconnection_detail", ), + path( + "api/v1/controller/batch-command/", + get_view("batch_command_list_view"), + name="batch_command_list", + ), + path( + "api/v1/controller/batch-command//", + get_view("batch_command_detail_view"), + name="batch_command_detail", + ), + path( + "api/v1/controller/batch-command//command/", + get_view("batch_command_result_list_view"), + name="batch_command_result_list", + ), + path( + "api/v1/controller/batch-command/execute/", + get_view("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..dd27a4043 100644 --- a/openwisp_controller/connection/api/views.py +++ b/openwisp_controller/connection/api/views.py @@ -1,14 +1,22 @@ +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_users.api.mixins import FilterByParentManaged +from openwisp_users.api.mixins import ProtectedAPIMixin as BaseProtectedAPIMixin from openwisp_utils.api.pagination import OpenWispPagination from ...mixins import ( @@ -17,6 +25,9 @@ RelatedDeviceProtectedAPIMixin, ) from .serializers import ( + BatchCommandDetailSerializer, + BatchCommandExecuteSerializer, + BatchCommandSerializer, CommandSerializer, CredentialSerializer, DeviceConnectionSerializer, @@ -26,6 +37,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 +150,88 @@ class DeviceConnectionListCreateView(BaseDeviceConnection, ListCreateAPIView): DeviceConnenctionListCreateView = DeviceConnectionListCreateView +class BatchCommandExecuteView(ProtectedAPIMixin, GenericAPIView): + model = BatchCommand + queryset = BatchCommand.objects.all() + serializer_class = BatchCommandExecuteSerializer + pagination_class = OpenWispPagination + + 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, + ) + page = self.paginate_queryset(data["devices"]) + return self.get_paginated_response([str(device.pk) for device in page]) + + +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 BatchCommandResultListView( + FilterByParentManaged, BaseProtectedAPIMixin, ListAPIView +): + model = Command + queryset = Command.objects.select_related("device").order_by("-created") + serializer_class = CommandSerializer + pagination_class = OpenWispPagination + + def get_parent_queryset(self): + return BatchCommand.objects.filter(pk=self.kwargs["batch_id"]) + + def get_queryset(self): + qs = super().get_queryset().filter(batch_command_id=self.kwargs["batch_id"]) + return BatchCommand.scope_commands(qs, self.request.user) + + class DeviceConnectionDetailView(BaseDeviceConnection, RetrieveUpdateDestroyAPIView): def get_object(self): queryset = self.filter_queryset(self.get_queryset()) @@ -149,6 +243,10 @@ 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() +batch_command_result_list_view = BatchCommandResultListView.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/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 38b410778..75661f9bd 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 @@ -14,6 +17,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 +33,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 +467,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") @@ -472,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 [ @@ -522,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" @@ -549,6 +575,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): @@ -585,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 @@ -711,3 +765,540 @@ 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=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 the name of the device and the validation " + "error that caused it to be skipped during command creation." + ), + ) + + class Meta: + abstract = True + verbose_name = _("Mass command") + verbose_name_plural = _("Mass commands") + + def __str__(self): + return self.label + + @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 + 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.")} + ) + + @classmethod + 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) + + 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 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.")} + ) + + 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 the target' + " organization(s)" + ).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 a queryset of the devices this batch command runs on. + + Until the batch is saved, those are the devices which match its + organization, group and location. execute() stores the devices it + matched, so a saved batch returns its stored selection as it is: + deleting its devices empties the selection, and matching the + organization, group and location again would hand back devices + which were never selected. Callers which walk the whole result + should consume it with iterator(). + """ + if not self._state.adding: + 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: + 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.order_by("-created") + + @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) + system_wide = kwargs.pop("system_wide", False) + batch = cls(**kwargs) + with transaction.atomic(): + batch.full_clean() + if devices_list is None: + # the batch is still unsaved, so this matches the + # organization, group and location + devices_list = list(batch.resolve_devices()) + batch.save() + batch.devices.set(devices_list) + else: + cls._validate_devices_org( + devices_list, batch.organization_id, system_wide=system_wide + ) + batch.save() + batch.devices.set(devices_list) + batch._validate_org_relations() + if not devices_list: + 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": batch.resolve_devices()} + + def _clean_sensitive_info(self): + if self.type == "change_password": + self.input = {"password": "********"} + + def _skip_transferred_devices(self): + """Records the devices which left the organization as skipped. + + The name is the one the device has when the batch runs, moments + after it was created, and the device belonged to this + organization when it was selected: only a rename made by the new + organization in that window would differ, which is not worth + storing a copy of every selected name up front. + """ + 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 + 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.status = "in-progress" + self.save(update_fields=["status"]) + Command = load_model("connection", "Command") + self.skipped_devices = {} + created_count = 0 + self._skip_transferred_devices() + for device in self.resolve_devices().iterator(): + command = Command( + device=device, + type=self.type, + input=self.input, + batch_command=self, + ) + try: + command.full_clean() + command._batch_index = created_count + command.save() + created_count += 1 + except ValidationError as 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, + self.pk, + e, + ) + if self.skipped_devices: + self.save(update_fields=["skipped_devices"]) + if not created_count and not self.skipped_devices: + self.status = "failed" + self.save(update_fields=["status"]) + else: + self.calculate_and_update_status() + self._clean_sensitive_info() + 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 + 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. + """ + 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 new file mode 100644 index 000000000..4f04b4e2c --- /dev/null +++ b/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py @@ -0,0 +1,177 @@ +# Generated by Django 5.2.15 on 2026-06-23 13:40 + +import uuid + +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.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 the name of the device and the " + "validation error that caused it to be 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/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..12ed0368e --- /dev/null +++ b/openwisp_controller/connection/static/connection/js/execute-command.js @@ -0,0 +1,270 @@ +"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"].filter(function (selector) { + return $(selector).length; + }); + if (!fields.length) { + return; + } + 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 d6ea2828b..dea4f8f03 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,9 +97,32 @@ def launch_command(command_id): ) command.status = "failed" command._add_output(_(f"Internal system error: {e}")) + command._clean_sensitive_info() command._save_without_resurrecting() +@shared_task +def launch_batch_command(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/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..93a745421 --- /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..c7bf6deef --- /dev/null +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/confirm_command.html @@ -0,0 +1,130 @@ +{% 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 }}
+
+
+
+
+ +
+ {{ device_count }} {% blocktrans count counter=device_count %}device{% plural %}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..de6572d7d --- /dev/null +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/execute_command.html @@ -0,0 +1,91 @@ +{% 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 %} +
+ {% 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 }} + + + {% 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 220604cef..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 @@ -65,6 +75,7 @@ def _get_expected_response(self, command): "output": command.output, "device": str(command.device_id), "connection": str(command.connection_id), + "batch_command": None, }, } @@ -106,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..8fbb25216 100644 --- a/openwisp_controller/connection/tests/test_admin.py +++ b/openwisp_controller/connection/tests/test_admin.py @@ -1,19 +1,39 @@ import json +import re +from html import unescape from unittest.mock import patch +from urllib.parse import parse_qs, urlparse +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 django.utils.html import escape 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, BatchCommandDeviceAdminMixin 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 +42,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 +178,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 +332,1466 @@ 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" + config_app_label = "config" + + def setUp(self): + self._create_admin() + self.execute_url = reverse(f"admin:{self.app_label}_batchcommand_execute") + self.changelist_url = reverse(f"admin:{self.app_label}_batchcommand_changelist") + self.device_changelist_url = reverse( + f"admin:{self.config_app_label}_device_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) + self._set_wizard(wizard) + 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(self._back_url()).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" + + def register_device_admin(admin_class): + if admin.site.is_registered(Device): + admin.site.unregister(Device) + admin.site.register(Device, admin_class) + + model_admin = BatchCommandAdmin(BatchCommand, admin.site) + device_admin_class = type(admin.site.get_model_admin(Device)) + self.addCleanup(register_device_admin, device_admin_class) + register_device_admin(ReplacementDeviceAdmin) + 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() + + 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 + + register_device_admin(BareDeviceAdmin) + self.assertEqual( + model_admin.get_device_changelist_template(), + "admin/change_list.html", + ) + register_device_admin(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() + + with self.subTest("no wizard in the session"): + response = self._post_confirm("any-token") + self.assertRedirects(response, self.execute_url) + self.assertIn( + "This mass command is not available anymore, so it was not" + " executed. Please fill in the details again.", + 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"], wizard_id=wizard["id"]) + batch = BatchCommand.objects.get() + response = self._post_confirm(wizard["token"], wizard_id=wizard["id"]) + self.assertRedirects(response, self.execute_url) + self.assertIn( + "This mass command is not available anymore, so it was not" + " executed. Please fill in the details again.", + 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(wizard["id"])) + 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_of_each_browser_tab(self): + """Covers the wizards the session keeps for the open browser tabs: + every tab executes the mass command it reviewed, the oldest wizards + are dropped, and a wizard which is gone is reported as such. + """ + org = self._get_org() + self._create_device(organization=org) + self._login() + + with self.subTest("the tab which started first still executes its own"): + first = self._start_wizard(organization=str(org.pk), label="first-tab") + self.client.get(self._confirm_url()) + second = self._start_wizard(organization=str(org.pk), label="second-tab") + self.client.get(self._confirm_url()) + self.assertNotEqual(first["id"], second["id"]) + response = self._post_confirm(first["token"], wizard_id=first["id"]) + batch = BatchCommand.objects.get() + self.assertRedirects( + response, + reverse( + f"admin:{self.app_label}_batchcommand_change", args=(batch.pk,) + ), + ) + self.assertEqual(batch.label, "first-tab") + self.assertEqual(list(self._get_wizards()), [second["id"]]) + + with self.subTest("the wizard of the other tab is still executable"): + response = self._post_confirm(second["token"], wizard_id=second["id"]) + self.assertEqual( + BatchCommand.objects.get(label="second-tab").label, "second-tab" + ) + self.assertEqual(self._get_wizards(), {}) + BatchCommand.objects.all().delete() + + with self.subTest("only the most recent wizards are kept"): + wizards = [ + self._start_wizard(organization=str(org.pk), label=f"tab-{index}") + for index in range(BatchCommandAdmin.max_wizards + 1) + ] + self.assertEqual( + list(self._get_wizards()), + [wizard["id"] for wizard in wizards[1:]], + ) + + with self.subTest("a wizard which was dropped is not executed"): + response = self._post_confirm( + wizards[0]["token"], wizard_id=wizards[0]["id"] + ) + self.assertRedirects(response, self.execute_url) + self.assertIn( + "This mass command is not available anymore, so it was not" + " executed. Please fill in the details again.", + self._messages(response), + ) + self.assertFalse(BatchCommand.objects.exists()) + + def test_wizard_pages_keep_their_wizard(self): + """The pages of a wizard carry its id, so paging through the device + table of an older tab keeps showing that tab's wizard even after + another tab started a newer one. + """ + org = self._get_org() + for index in range(BatchCommandDeviceAdminMixin.list_per_page + 1): + self._create_device( + name=f"device-{index}", + mac_address=f"00:11:22:33:44:{index:02x}", + organization=org, + ) + self._login() + response = self._post_execute(organization=str(org.pk), label="older-tab") + older = list(self._get_wizards().values())[-1] + self.assertRedirects(response, self._confirm_url(older["id"])) + self._start_wizard(organization=str(org.pk), label="newer-tab") + + with self.subTest("the confirm page shows the wizard of its id"): + response = self.client.get(self._confirm_url(older["id"])) + self.assertEqual(response.context["wizard"]["label"], "older-tab") + self.assertContains(response, 'href="?p=2"') + self.assertContains( + response, f'href="{escape(self._back_url(older["id"]))}"' + ) + + with self.subTest("the next page keeps the wizard of its id"): + response = self.client.get(f"{self._confirm_url(older['id'])}?p=2") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["wizard"]["label"], "older-tab") + + with self.subTest("going back keeps the wizard of its id"): + response = self.client.get(self._back_url(older["id"])) + self.assertEqual(response.context["form"].initial["label"], "older-tab") + + 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(wizard["id"]), + {"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, wizard["id"]) + first.session.save() + model_admin._execute_batch_command(second, wizard["id"]) + 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(wizard["id"])) + 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_device_action_selection(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + devices = [ + self._create_device( + name=f"device{index}", + mac_address=f"00:11:22:33:44:0{index}", + organization=org, + ) + for index in range(2) + ] + device_org2 = self._create_device( + name="device-org2", mac_address="00:11:22:33:44:09", organization=org2 + ) + self._login() + + with self.subTest("the selection prefills the form"): + response = self._post_device_action(devices) + self.assertEqual(response.status_code, 200) + form = response.context["form"] + self.assertEqual(response.context["device_count"], 2) + self.assertEqual( + set(form.device_ids), {str(device.pk) for device in devices} + ) + self.assertEqual(form.fields["devices"].initial, ",".join(form.device_ids)) + self.assertEqual(form.fields["organization"].initial, str(org.pk)) + for field_name in ("organization", "group", "location"): + self.assertTrue(form.fields[field_name].disabled) + + with self.subTest("the selection is announced and the wider targets hidden"): + self.assertContains( + response, "The command will run on the 2 devices you selected." + ) + self.assertContains(response, 'name="devices"') + self.assertNotContains(response, 'name="group"') + self.assertNotContains(response, 'name="location"') + + with self.subTest("devices of different organizations are refused"): + response = self._post_device_action([devices[0], device_org2]) + self.assertRedirects(response, self.device_changelist_url) + self.assertIn( + "All devices must belong to the same organization", + " ".join(self._messages(response)), + ) + + with self.subTest("the selection travels to the review step"): + response = self._post_execute(devices=self._pk_list(devices)) + self.assertEqual(response.status_code, 302) + wizard = list(self._get_wizards().values())[-1] + self.assertEqual( + set(wizard["device_ids"]), {str(device.pk) for device in devices} + ) + response = self.client.get(self._confirm_url()) + self.assertEqual(response.context["device_count"], 2) + self.assertEqual(response.context["targets_display"], "2 selected devices") + self.assertEqual(set(response.context["cl"].queryset), set(devices)) + + with self.subTest("only the selected devices are executed"): + self._post_confirm(wizard["token"]) + batch = BatchCommand.objects.get() + self.assertEqual(set(batch.devices.all()), set(devices)) + + with self.subTest("the texts use the singular when one device is selected"): + response = self._post_device_action(devices[:1]) + self.assertContains( + response, "The command will run on the device you selected." + ) + self.assertNotContains(response, "devices you selected") + self._start_wizard(devices=self._pk_list(devices[:1])) + response = self.client.get(self._confirm_url()) + self.assertEqual(response.context["targets_display"], "1 selected device") + + with self.subTest("the selection is kept when going back to edit"): + self._create_device( + name="device-not-selected", + mac_address="00:11:22:33:44:08", + organization=org, + ) + selected = {str(device.pk) for device in devices} + self._start_wizard(devices=self._pk_list(devices)) + self.client.get(self._confirm_url()) + response = self.client.get(self._back_url()) + form = response.context["form"] + self.assertEqual(set(form.device_ids), selected) + for field_name in ("organization", "group", "location"): + self.assertTrue(form.fields[field_name].disabled) + self.assertContains( + response, "The command will run on the 2 devices you selected." + ) + self._start_wizard( + devices=form.fields["devices"].initial or "", label="edited-label" + ) + wizard = list(self._get_wizards().values())[-1] + self.assertEqual(wizard["label"], "edited-label") + self.assertEqual(set(wizard["device_ids"]), selected) + response = self.client.get(self._confirm_url()) + self.assertEqual(set(response.context["cl"].queryset), set(devices)) + + def test_device_action_system_wide(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + org1_devices = [ + self._create_device( + name=f"device{index}", + mac_address=f"00:11:22:33:44:0{index}", + organization=org, + ) + for index in range(2) + ] + device_org2 = self._create_device( + name="device-org2", mac_address="00:11:22:33:44:09", organization=org2 + ) + self._login() + + with self.subTest("selecting every device runs the command on all of them"): + all_devices = org1_devices + [device_org2] + response = self._post_device_action(all_devices, select_across=True) + self.assertEqual(response.status_code, 200) + form = response.context["form"] + self.assertEqual(form.device_ids, []) + self.assertEqual(response.context["device_count"], 0) + self.assertIsNone(form.fields["organization"].initial) + for field_name in ("organization", "group", "location"): + self.assertFalse(form.fields[field_name].disabled) + self.assertContains(response, "The command will run on all devices.") + self.assertNotContains(response, 'name="organization"') + self.assertNotContains(response, 'name="group"') + self.assertNotContains(response, 'name="location"') + wizard = self._start_wizard() + response = self.client.get(self._confirm_url()) + self.assertEqual(response.context["targets_display"], "All devices") + self.assertEqual(set(response.context["cl"].queryset), set(all_devices)) + response = self._post_confirm(wizard["token"]) + self.assertIn( + "Mass command executed successfully.", self._messages(response) + ) + batch = BatchCommand.objects.get() + self.assertIsNone(batch.organization_id) + self.assertEqual(set(batch.devices.all()), set(all_devices)) + BatchCommand.objects.all().delete() + + with self.subTest("the excluded devices are left out of the batch"): + wizard = self._start_wizard() + self.client.get(self._confirm_url()) + response = self._post_confirm(wizard["token"], excluded=str(device_org2.pk)) + self.assertEqual(response.status_code, 302) + self.assertIn( + "Mass command executed successfully.", self._messages(response) + ) + batch = BatchCommand.objects.get() + self.assertIsNone(batch.organization_id) + self.assertIsNone(batch.group_id) + self.assertIsNone(batch.location_id) + self.assertEqual(set(batch.devices.all()), set(org1_devices)) + + with self.subTest("a partial multi organization selection is refused"): + self._create_device( + name="excluded-by-the-search", + mac_address="00:11:22:33:44:08", + organization=org2, + ) + response = self._post_device_action( + org1_devices + [device_org2], + select_across=True, + query={"q": "device"}, + ) + self.assertRedirects(response, f"{self.device_changelist_url}?q=device") + self.assertIn( + "All devices must belong to the same organization", + " ".join(self._messages(response)), + ) + + def test_device_action_permissions_and_scope(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device = self._create_device(organization=org) + device2 = self._create_device( + name="device2", mac_address="00:11:22:33:44:02", organization=org2 + ) + device_admin = admin.site.get_model_admin(Device) + request = RequestFactory().get(self.device_changelist_url) + action_name = "execute_mass_command_admin_action" + + with self.subTest("the device change 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") + ) + request.user = viewer + self.assertNotIn(action_name, device_admin.get_actions(request)) + + with self.subTest("the operator group can use the action"): + operator = self._create_operator(organizations=[org]) + request.user = operator + self.assertIn(action_name, device_admin.get_actions(request)) + + with self.subTest("the batch command add permission is enforced"): + viewer.user_permissions.set( + Permission.objects.filter( + codename__in=["view_device", "change_device", "view_batchcommand"] + ) + ) + self.client.force_login(viewer) + self.assertEqual(self._post_device_action([device]).status_code, 403) + + with self.subTest("devices of unmanaged organizations are dropped"): + self.client.force_login(operator) + response = self._post_execute(devices=self._pk_list([device, device2])) + form = response.context["form"] + self.assertEqual(form.device_ids, [str(device.pk)]) + self.assertIn( + "Some of the selected devices are no longer available.", + form.errors["__all__"], + ) + + with self.subTest("devices which disappeared are dropped"): + self._login() + response = self._post_execute(devices=f"{device.pk},{uuid4()}") + self.assertEqual(response.context["form"].device_ids, [str(device.pk)]) + + with self.subTest("mixed organizations are refused by the form"): + response = self._post_execute(devices=self._pk_list([device, device2])) + form = response.context["form"] + self.assertIsNone(form.fields["organization"].initial) + self.assertIn( + "All devices must belong to the same organization", + " ".join(form.errors["__all__"]), + ) + + with self.subTest("selecting every device is not system wide for operators"): + multi_operator = self._create_operator( + organizations=[org, org2], username="multi", email="multi@test.com" + ) + self.client.force_login(multi_operator) + response = self._post_device_action([device, device2], select_across=True) + self.assertRedirects(response, self.device_changelist_url) + self.assertIn( + "All devices must belong to the same organization", + " ".join(self._messages(response)), + ) + + with self.subTest("an operator executes the devices it manages"): + self.client.force_login(operator) + wizard = self._start_wizard(devices=self._pk_list([device])) + self.assertEqual(wizard["device_ids"], [str(device.pk)]) + self.assertEqual(wizard["organization_id"], str(org.pk)) + self.client.get(self._confirm_url()) + response = self._post_confirm(wizard["token"]) + self.assertEqual(response.status_code, 302) + batch = BatchCommand.objects.get() + self.assertEqual(batch.organization_id, org.pk) + self.assertEqual(set(batch.devices.all()), {device}) + + 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( + self._create_command( + device_conn=connection, batch_command=batch, **kwargs + ) + ) + return commands + + def test_change_view_pagination_links(self): + """The links keep the filters of the page and encode hostile keys. + + The keys come from the query string, so a key which contains a + delimiter must not be able to add a parameter of its own. + """ + org = self._get_org() + batch = self._create_batch_command(organization=org) + devices = [ + self._create_device( + name=f"page-dev{index}", + mac_address=f"00:11:22:33:4a:{index:02x}", + organization=org, + ) + for index in range(4) + ] + self._create_commands(batch, devices) + self._login() + url = reverse(f"admin:{self.app_label}_batchcommand_change", args=[batch.pk]) + with patch.object(BatchCommandAdmin, "device_commands_per_page", 3): + response = self.client.get(f"{url}?status=in-progress&a%26page=9&page=1") + links = [ + parse_qs(urlparse(unescape(href)).query) + for href in re.findall( + r'\s*(?:Previous|Next)', + response.content.decode(), + ) + ] + self.assertEqual(len(links), 1, msg="only the next link is expected") + self.assertEqual(links[0]["status"], ["in-progress"]) + self.assertEqual(links[0]["page"], ["2"]) + self.assertEqual(links[0]["a&page"], ["9"]) + + 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 6e3a827ab..dde9c8948 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,24 @@ from openwisp_users.tests.test_api import AuthenticationMixin from .. import settings as app_settings -from ..api.views import CommandListCreateView +from ..api.views import ( + BatchCommandExecuteView, + BatchCommandListView, + BatchCommandResultListView, + 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 +869,1713 @@ def test_deviceconnection_unauthenticated_user(self): "delete": 401, }, ) + + +class TestBatchCommandsAPI( + TestAdminMixin, AuthenticationMixin, TestCase, CreateCommandMixin +): + 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_result_list(self): + org = self._get_org() + batch = self._create_batch_command(organization=org) + other_batch = self._create_batch_command(organization=org) + credentials = self._create_credentials(organization=org) + commands = [] + for index in range(3): + device = self._create_device( + name=f"result-dev{index}", + mac_address=f"00:11:22:33:46:{index:02x}", + organization=org, + ) + self._create_config(device=device) + connection = self._create_device_connection( + device=device, credentials=credentials + ) + with patch.object(Command, "_schedule_command"): + commands.append( + self._create_command( + device_conn=connection, + batch_command=batch, + status="success", + output="done\n", + ) + ) + with patch.object(Command, "_schedule_command"): + other_command = self._create_command( + device_conn=connection, batch_command=other_batch + ) + url = reverse("connection_api:batch_command_result_list", args=[batch.pk]) + + with self.subTest("lists the commands of the mass command"): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 3) + listed = [result["id"] for result in response.data["results"]] + self.assertNotIn(str(other_command.pk), listed) + self.assertEqual(set(listed), {str(command.pk) for command in commands}) + result = response.data["results"][0] + self.assertEqual(result["batch_command"], str(batch.pk)) + self.assertEqual(result["status"], "success") + self.assertEqual(result["output"], "done\n") + self.assertIn("device", result) + + with self.subTest("the newest command is listed first"): + created = [result["created"] for result in response.data["results"]] + self.assertEqual(created, sorted(created, reverse=True)) + + with self.subTest("an unknown mass command is not found"): + response = self.client.get( + reverse("connection_api:batch_command_result_list", args=[uuid.uuid4()]) + ) + self.assertEqual(response.status_code, 404) + + with patch.object( + BatchCommandResultListView, "pagination_page_size", 2, create=True + ): + with self.subTest("pagination page 1"): + response = self.client.get(f"{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(f"{url}?page=2") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + + def test_batch_command_result_list_hides_transferred_device(self): + """A device can be moved to another organization after its command. + + Belonging to the mass command is then not enough to be listed, so + the command is hidden from everyone but a superuser. + """ + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + batch = self._create_batch_command(organization=org) + device = self._create_device( + name="moved-dev", + mac_address="00:11:22:33:47:01", + organization=org2, + ) + self._create_config(device=device) + connection = self._create_device_connection( + device=device, credentials=self._create_credentials(organization=org2) + ) + with patch.object(Command, "_schedule_command"): + self._create_command(device_conn=connection, batch_command=batch) + url = reverse("connection_api:batch_command_result_list", args=[batch.pk]) + + with self.subTest("a superuser sees the command"): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + with self.subTest("a manager of the mass command does not"): + self.client.logout() + operator = self._create_operator(organizations=[org]) + operator.user_permissions.add( + Permission.objects.get(codename="view_batchcommand") + ) + self.client.force_login(operator) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + + 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["results"], [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["results"]) + self.assertNotIn(str(device2.pk), response.data["results"]) + + 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["results"]) + self.assertNotIn(str(device1.pk), response.data["results"]) + + 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["results"]) + self.assertNotIn(str(device2.pk), response.data["results"]) + + 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["results"]) + self.assertIn(str(device2.pk), response.data["results"]) + + 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["results"]) + self.assertIn(str(device2.pk), response.data["results"]) + + def test_batch_command_dry_run_pagination(self): + org = self._get_org() + devices = [ + self._create_device( + name=f"dryp-dev{index}", + mac_address=f"00:11:22:33:4d:{index:02x}", + organization=org, + ) + for index in range(5) + ] + url = "{0}?organization={1}".format( + reverse("connection_api:batch_command_execute"), str(org.pk) + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 5) + self.assertEqual(len(response.data["results"]), 5) + with patch.object( + BatchCommandExecuteView, "pagination_page_size", 2, create=True + ): + pages = [] + + with self.subTest("first page"): + response = self.client.get(f"{url}&page=1") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 5) + self.assertEqual(len(response.data["results"]), 2) + self.assertIsNotNone(response.data["next"]) + pages += response.data["results"] + + with self.subTest("second page"): + response = self.client.get(f"{url}&page=2") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 2) + pages += response.data["results"] + + with self.subTest("last page"): + response = self.client.get(f"{url}&page=3") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertIsNone(response.data["next"]) + pages += response.data["results"] + + with self.subTest("the pages do not overlap"): + self.assertEqual(len(set(pages)), 5) + self.assertEqual(set(pages), {str(device.pk) for device in 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(15): + 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(13): + 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(11): + 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 the target organization(s)', + 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)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + 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) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["results"]) + self.assertIn(str(device2.pk), response.data["results"]) + + 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["results"]) + + 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, 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() + 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("results", 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("results", 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) + + with self.subTest("results own org"): + url = reverse( + "connection_api:batch_command_result_list", + args=[batch_org1.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + + with self.subTest("results cross-org"): + url = reverse( + "connection_api:batch_command_result_list", + args=[batch_org2.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + 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("results return 404 for org-wide batch"): + url = reverse("connection_api:batch_command_result_list", 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("Results"): + url = reverse( + "connection_api:batch_command_result_list", + 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."]}, + ) + + 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."]}, + ) + + 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 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", + } + 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.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()) + 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) + self.assertEqual(detail_resp.data["skipped_devices"], batch.skipped_devices) + + 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) + 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("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", + args=[batch.pk], + ) + detail_response = self.client.get(detail_url) + self.assertEqual(detail_response.status_code, 200) + self.assertEqual( + detail_response.data["skipped_devices"], batch.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() + 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("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", + args=[batch.pk], + ) + detail_response = self.client.get(detail_url) + self.assertEqual(detail_response.status_code, 200) + 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 e3ed6bb97..460e9d254 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,12 +28,17 @@ 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") 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 +467,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() @@ -521,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( @@ -968,6 +1000,1244 @@ 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_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") + 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 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], + ) + + 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) + 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)]["error"], + ) + + 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], + ) + 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)]["error"], + ) + + 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], + ) + 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.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) + 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("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", + 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], + ) + 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)]["error"], + ) + self.assertIn(str(device_deactivated.pk), batch.skipped_devices) + self.assertIn( + "Device is deactivated", + 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 = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + 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( + 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], + ) + self.assertEqual(list(batch.resolve_devices()), [device1]) + + with self.subTest("organization-scoped (no explicit devices)"): + batch = BatchCommand(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 = BatchCommand(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 = BatchCommand(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 = BatchCommand( + 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 = BatchCommand( + organization=org, + group=empty_group, + ) + resolved = list(batch.resolve_devices()) + self.assertEqual(resolved, []) + + def test_batch_command_emptied_selection_does_not_target_other_devices(self): + org = self._get_org() + selected = self._create_device( + name="selected", + mac_address="00:11:22:33:44:11", + organization=org, + ) + other = self._create_device( + name="other", + mac_address="00:11:22:33:44:12", + organization=org, + ) + credentials = self._create_credentials(organization=org) + for device in (selected, other): + self._create_config(device=device) + self._create_device_connection(device=device, credentials=credentials) + + for organization in (org, None): + with self.subTest(organization=organization): + batch = self._create_batch_command( + organization=organization, + devices=[selected], + ) + batch.devices.clear() + self.assertEqual(list(batch.resolve_devices()), []) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 0) + self.assertEqual(batch.skipped_devices, {}) + self.assertFalse(other.command_set.exists()) + + def test_batch_command_without_devices_fails(self): + """A batch which ran on no device must not report idle. + + The selection is emptied when the devices it holds are deleted, + so the batch creates no command. "idle" means the batch did not + start yet, and no command is left to calculate the status from, + so it would stay idle and be accepted for another launch. + """ + org = self._get_org() + device = self._create_device( + name="repro-device", + mac_address="00:11:22:33:44:21", + organization=org, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="repro", + devices=[device], + ) + self.assertEqual(batch.status, "idle") + self.assertEqual(list(batch.devices.all()), [device]) + device.deactivate() + device.config.set_status_deactivated() + device.delete(check_deactivated=False) + batch.refresh_from_db() + self.assertEqual(list(batch.devices.all()), []) + batch.create_commands() + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 0) + self.assertEqual(batch.skipped_devices, {}) + self.assertEqual(batch.status, "failed") + self.assertFalse(BatchCommand.objects.filter(pk=batch.pk, status="idle")) + + 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("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", + 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("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", + 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): {"name": device.name, "error": "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_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" + ) + 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): @@ -1277,3 +2547,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 e70c67abd..6a53b1107 100644 --- a/openwisp_controller/connection/tests/test_selenium.py +++ b/openwisp_controller/connection/tests/test_selenium.py @@ -1,14 +1,51 @@ +from time import sleep +from unittest.mock import patch +from urllib.parse import quote, urlparse +from uuid import UUID, uuid4 + +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 Select, 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 ..admin import BatchCommandAdmin, BatchCommandDeviceAdminMixin +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") @@ -53,13 +90,1772 @@ 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) + + +@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.changelist_url = reverse(f"admin:{self.app_label}_batchcommand_changelist") + self.device_changelist_url = reverse( + f"admin:{self.config_app_label}_device_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() + # the editor is rebuilt when the schema request lands, discarding + # whatever was typed before it did + WebDriverWait(self.web_driver, 5).until( + lambda driver: driver.execute_script( + "return Object.keys(django._schemas).length > 0;" + ) + ) + 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 _execute_mass_command_action(self, devices): + self.open(self.device_changelist_url) + self._wait_for_url(self.device_changelist_url) + self.hide_loading_overlay() + for device in devices: + self.find_element( + by=By.CSS_SELECTOR, value=f'.action-select[value="{device.pk}"]' + ).click() + Select(self.find_element(by=By.NAME, value="action")).select_by_value( + "execute_mass_command_admin_action" + ) + self.find_element(by=By.CSS_SELECTOR, value='button[name="index"]').click() + self.wait_for_visibility(By.ID, "review-command-btn", timeout=5) + self.hide_loading_overlay() + + def _wait_for_review_page(self): + """Waits for the confirm page and remembers its path, which carries + the id of the wizard it belongs to.""" + WebDriverWait(self.web_driver, 5).until( + lambda driver: urlparse(driver.current_url).path.startswith( + f"{self.changelist_url}confirm/" + ) + ) + self.review_path = urlparse(self.web_driver.current_url).path + 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.review_path) + 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_batch_command_from_device_changelist(self): + org = self._get_org() + devices = self._create_devices( + org, + 2, + credentials=self._create_credentials_with_key( + organization=org, port=self.ssh_server.port + ), + ) + self.login() + self._execute_mass_command_action(devices) + + with self.subTest("the selection replaces the wider targets"): + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value=".messagelist li.warning" + ).text, + "The command will run on the 2 devices you selected.", + ) + devices_field = self.find_element( + by=By.ID, value="id_devices", wait_for="presence" + ) + self.assertEqual( + set(devices_field.get_attribute("value").split(",")), + {str(device.pk) for device in devices}, + ) + self.assertTrue( + self.find_element( + by=By.ID, value="id_organization", wait_for="presence" + ).get_attribute("disabled") + ) + self.assertEqual(self.web_driver.find_elements(By.ID, "id_group"), []) + self.assertEqual(self.web_driver.find_elements(By.ID, "id_location"), []) + + with self.subTest("the command runs on the selected devices"): + self._fill_wizard( + type="Custom commands", + label="selection-custom", + command_input={"command": "echo test"}, + 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["Targets"], "2 selected devices") + self.assertEqual(summary["Will run on"], "2 devices") + self.assertEqual(self._device_names(), [device.name for device in devices]) + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("selection-custom", "success", 2) + self.assertEqual( + sorted(self._command_device_names()), + sorted(device.name for device in devices), + ) + + def test_execute_system_wide_batch_command_from_device_changelist(self): + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + devices = self._create_devices( + org1, + 2, + credentials=self._create_credentials_with_key( + organization=org1, port=self.ssh_server.port + ), + ) + self._create_devices( + org2, + 1, + credentials=self._create_credentials_with_key( + organization=org2, port=self.ssh_server.port + ), + ) + self.login() + self._execute_mass_command_action(devices) + + with self.subTest("the targets are hidden and the scope announced"): + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value="ul.messagelist li.warning" + ).text, + "The command will run on all devices.", + ) + self.assertEqual( + self.web_driver.find_elements(By.ID, "id_organization"), [] + ) + self.assertEqual(self.web_driver.find_elements(By.ID, "id_group"), []) + self.assertEqual(self.web_driver.find_elements(By.ID, "id_location"), []) + self.assertEqual( + self.find_element( + by=By.ID, value="id_devices", wait_for="presence" + ).get_attribute("value"), + "", + ) + + with self.subTest("the command runs on every device"): + self._fill_wizard( + type="Custom commands", + label="system-wide-custom", + command_input={"command": "echo test"}, + open_page=False, + ) + # the editor commits what was typed only when the field is left + WebDriverWait(self.web_driver, 5).until( + lambda driver: not driver.execute_script( + "return django._jsonEditors.id_input_jsoneditor.validate();" + ) + ) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + summary = self._summary() + self.assertEqual(summary["Targets"], "All devices") + self.assertEqual(summary["Will run on"], "3 devices") + self.assertEqual( + sorted(self._device_names()), + sorted(device.name for device in devices), + ) + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("system-wide-custom", "success", 3) + + self.assertEqual(self.get_browser_errors(), []) + + def test_device_action_organization_isolation_and_permissions(self): + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + devices1 = self._create_devices( + org1, + 2, + credentials=self._create_credentials_with_key( + organization=org1, port=self.ssh_server.port + ), + ) + self._create_devices(org2, 1) + operator = self._create_operator(organizations=[org1]) + self.login(username=operator.username, password="tester") + self._execute_mass_command_action(devices1) + + with self.subTest("the selection is scoped to the managed organization"): + self.assertEqual( + self.find_element( + by=By.CSS_SELECTOR, value="ul.messagelist li.warning" + ).text, + "The command will run on the 2 devices you selected.", + ) + organization = self.find_element( + by=By.ID, value="id_organization", wait_for="presence" + ) + self.assertTrue(organization.get_attribute("disabled")) + self.assertEqual(organization.get_attribute("value"), str(org1.pk)) + self.assertEqual(self.web_driver.find_elements(By.ID, "id_group"), []) + self.assertEqual(self.web_driver.find_elements(By.ID, "id_location"), []) + + with self.subTest("the command runs on the selected devices"): + self._fill_wizard( + type="Custom commands", + label="operator-selection", + command_input={"command": "echo test"}, + 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["Targets"], "2 selected 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("operator-selection", "success", 2) + batch = BatchCommand.objects.get(label="operator-selection") + self.assertEqual(batch.organization_id, org1.pk) + + with self.subTest("the batch command add permission is enforced"): + restricted = self._create_operator( + organizations=[org1], + username="restricted", + email="restricted@test.com", + ) + restricted.groups.clear() + restricted.user_permissions.set( + Permission.objects.filter(codename__in=["view_device", "change_device"]) + ) + self.web_driver.delete_all_cookies() + self.login(username=restricted.username, password="tester") + self.open(self.device_changelist_url) + self._wait_for_url(self.device_changelist_url) + self.hide_loading_overlay() + self.find_element( + by=By.CSS_SELECTOR, value=f'.action-select[value="{devices1[0].pk}"]' + ).click() + Select(self.find_element(by=By.NAME, value="action")).select_by_value( + "execute_mass_command_admin_action" + ) + self.find_element(by=By.CSS_SELECTOR, value='button[name="index"]').click() + self.assertEqual( + self.find_element(by=By.TAG_NAME, value="body").text, + "403 Forbidden", + ) + + with self.subTest("the view permission does not offer the action"): + viewer = self._create_operator( + organizations=[org1], username="viewer", email="viewer@test.com" + ) + viewer.groups.clear() + viewer.user_permissions.set( + Permission.objects.filter( + codename__in=["view_device", "view_batchcommand"] + ) + ) + self.web_driver.delete_all_cookies() + self.login(username=viewer.username, password="tester") + self.open(self.device_changelist_url) + self._wait_for_url(self.device_changelist_url) + self.hide_loading_overlay() + self.assertNotIn( + "Execute mass command", + [ + option.text + for option in self.web_driver.find_elements( + By.CSS_SELECTOR, "select[name='action'] option" + ) + ], + ) + + 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.review_path}?p=2") + self._wait_for_url(self.review_path) + 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.review_path) + self._wait_for_url(self.review_path) + 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", + ) + confirm_url = reverse( + f"admin:{self.app_label}_batchcommand_confirm", args=[uuid4().hex] + ) + self.web_driver.get(f"{self.live_server_url}{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_wizards_of_two_browser_tabs(self): + """Two browser tabs share the session, but each one reviews and + executes its own mass command, also when the tab which started first + is used after the other one. + """ + org = self._get_org() + # one device more than a page, so that the review page has a second one + devices = self._create_devices( + org, BatchCommandDeviceAdminMixin.list_per_page + 1 + ) + device_names = {device.name for device in devices} + rows_per_page = BatchCommandAdmin.device_commands_per_page + + def change_page(): + return { + name: self.find_element( + by=By.CSS_SELECTOR, value=f".field-{name} .readonly" + ).text + for name in ( + "label", + "type", + "organization_display", + "affected_devices", + ) + } + + def expected_change_page(label): + return { + "label": label, + "type": "Reboot", + "organization_display": org.name, + "affected_devices": str(len(devices)), + } + + self.login() + first_tab = self.web_driver.current_window_handle + self._fill_wizard(type="Reboot", label="first-tab", organization=org) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + first_review = self.review_path + self.web_driver.switch_to.new_window("tab") + second_tab = self.web_driver.current_window_handle + self.addCleanup(self.web_driver.switch_to.window, first_tab) + self.addCleanup(self.web_driver.close) + self.addCleanup(self.web_driver.switch_to.window, second_tab) + self._fill_wizard(type="Reboot", label="second-tab", organization=org) + self.find_element(by=By.ID, value="review-command-btn").click() + self._wait_for_review_page() + self.web_driver.switch_to.window(first_tab) + + with self.subTest("the first tab keeps its wizard on the next page"): + self.open(f"{first_review}?p=2") + self._wait_for_url(first_review) + self.hide_loading_overlay() + self.assertEqual(self._summary()["Label"], "first-tab") + + with self.subTest("the first tab executes its own mass command"): + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("first-tab", "failed", rows_per_page) + self.assertEqual(change_page(), expected_change_page("first-tab")) + self.assertLessEqual(set(self._command_device_names()), device_names) + self.assertFalse(BatchCommand.objects.filter(label="second-tab").exists()) + + with self.subTest("the second tab executes its own mass command"): + self.web_driver.switch_to.window(second_tab) + self.assertEqual(self._summary()["Label"], "second-tab") + self.find_element(by=By.ID, value="execute-button").click() + self._wait_for_batch_result("second-tab", "failed", rows_per_page) + self.assertEqual(change_page(), expected_change_page("second-tab")) + self.assertLessEqual(set(self._command_device_names()), device_names) + + with self.subTest("the first tab still shows its own mass command"): + self.web_driver.switch_to.window(first_tab) + self.assertEqual(change_page(), expected_change_page("first-tab")) + self.web_driver.refresh() + self.assertEqual(change_page(), expected_change_page("first-tab")) + + 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 587c08209..5ec498c64 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -1,6 +1,5 @@ +import json import uuid -from contextlib import redirect_stderr -from io import StringIO from unittest import mock from celery.exceptions import SoftTimeLimitExceeded @@ -8,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 @@ -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): @@ -163,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, @@ -176,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" ) @@ -315,3 +341,188 @@ 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") + + @capture_stderr() + @mock.patch( + "openwisp_controller.connection.base.models.AbstractBatchCommand" + ".create_commands", + side_effect=RuntimeError("test error"), + ) + 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) + + with self.subTest("custom command"): + 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) + 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) + 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, stderr, 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() + 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): + 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..112bc392d 100644 --- a/openwisp_controller/connection/tests/utils.py +++ b/openwisp_controller/connection/tests/utils.py @@ -1,16 +1,24 @@ import os +from uuid import uuid4 +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME +from django.contrib.messages import get_messages +from django.urls import reverse +from django.utils.http import urlencode 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") Command = load_model("connection", "Command") +BatchCommand = load_model("connection", "BatchCommand") class SshServer(Server): @@ -118,6 +126,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): @@ -129,9 +155,81 @@ def _create_command(self, device_conn=None, device_conn_opts={}, **kwargs): "type": "custom", "input": {"command": "echo test"}, } + opts.update(kwargs) return Command.objects.create(**opts) +class BatchCommandMixin(TestAdminMixin, CreateCommandMixin): + @staticmethod + def _pk_list(devices): + return ",".join(str(device.pk) for device in devices) + + def _post_device_action( + self, + devices, + action="execute_mass_command_admin_action", + select_across=False, + query=None, + ): + data = { + "action": action, + ACTION_CHECKBOX_NAME: [str(device.pk) for device in devices], + } + if select_across: + data["select_across"] = "1" + url = self.device_changelist_url + if query: + url = f"{url}?{urlencode(query)}" + return self.client.post(url, data) + + 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 list(self._get_wizards().values())[-1] + + def _get_wizards(self): + return self.client.session.get(BatchCommandAdmin.session_key) or {} + + def _set_wizard(self, wizard): + session = self.client.session + session[BatchCommandAdmin.session_key] = {wizard["id"]: wizard} + session.save() + + def _confirm_url(self, wizard_id=None): + """Returns the confirm page of the given wizard, or of the last one.""" + if wizard_id is None: + wizard_id = next(reversed(self._get_wizards()), uuid4().hex) + return reverse(f"admin:{self.app_label}_batchcommand_confirm", args=[wizard_id]) + + def _back_url(self, wizard_id=None): + """Returns the URL of the back button of the given wizard, or of the + last one.""" + if wizard_id is None: + wizard_id = next(reversed(self._get_wizards())) + return f"{self.execute_url}?back=1&wizard={wizard_id}" + + def _post_confirm(self, token, excluded="", wizard_id=None): + return self.client.post( + self._confirm_url(wizard_id), {"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: @@ -141,3 +239,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/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..35e0b9eb0 100644 --- a/tests/openwisp2/sample_connection/api/views.py +++ b/tests/openwisp2/sample_connection/api/views.py @@ -1,3 +1,15 @@ +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 ( + BatchCommandResultListView as BaseBatchCommandResultListView, +) from openwisp_controller.connection.api.views import ( CommandDetailsView as BaseCommandDetailsView, ) @@ -42,9 +54,29 @@ class DeviceConnectionDetailView(BaseDeviceConnectionDetailView): pass +class BatchCommandExecuteView(BaseBatchCommandExecuteView): + pass + + +class BatchCommandListView(BaseBatchCommandListView): + pass + + +class BatchCommandDetailView(BaseBatchCommandDetailView): + pass + + +class BatchCommandResultListView(BaseBatchCommandResultListView): + 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() +batch_command_result_list_view = BatchCommandResultListView.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..2bca5329c --- /dev/null +++ b/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py @@ -0,0 +1,172 @@ +# Generated by Django 5.2.15 on 2026-06-15 18:15 + +import uuid + +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.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 the name of the device and the " + "validation error that caused it to be 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/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..ec1ea0eb5 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,11 @@ class TestCommandInlines(BaseTestCommandInlines): config_app_label = "sample_config" +class TestBatchCommandAdmin(BaseTestBatchCommandAdmin): + app_label = "sample_connection" + config_app_label = "sample_config" + + class TestModels(BaseTestModels): app_label = "sample_connection" @@ -60,6 +74,15 @@ class TestConnectionApi(BaseTestConnectionApi): pass +class TestBatchCommandsAPI(BaseTestBatchCommandsAPI): + pass + + +class TestBatchCommandsAPITransaction(BaseTestBatchCommandsAPITransaction): + pass + + +del BaseTestBatchCommandAdmin del BaseTestCommandInlines del BaseTestConnectionAdmin del BaseTestModels @@ -69,3 +92,5 @@ class TestConnectionApi(BaseTestConnectionApi): del BaseTestNotifications del BaseTestNotificationTransaction del BaseTestConnectionApi +del BaseTestBatchCommandsAPI +del BaseTestBatchCommandsAPITransaction 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" )