diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 062131170..1aae2d006 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: @@ -100,7 +100,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/user/rest-api.rst b/docs/user/rest-api.rst index c9702920d..a023c957b 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -481,6 +481,113 @@ Get Command Details GET /api/v1/controller/device/{device_id}/command/{command_id}/ +.. _controller_batch_command_api: + +Dry-Run Batch Command +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + GET /api/v1/controller/batch-command/execute/ + +Returns the list of devices that would be targeted without executing +anything. Useful for previewing which devices are affected. + +**Query Parameters:** + +================ ================================================= +Parameter Description +================ ================================================= +``organization`` Organization UUID (optional) +``type`` Command type (optional for dry-run) +``input`` Input data for the command (optional for dry-run) +``devices`` Comma-separated device UUIDs (optional) +``group`` Device group UUID (optional) +``location`` Location UUID (optional) +``execute_all`` Set to ``true`` to target all devices (optional) +================ ================================================= + +Execute a Batch 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) +``type`` Type of command to execute (**required**) +``input`` Input data for the command (**required**) +``label`` A short label to identify this batch command + (**required**) +``notes`` Optional notes (optional) +``devices`` List of device UUIDs (optional) +``group`` Device group UUID (optional) +``location`` Location UUID (optional) +``execute_all`` Set to ``true`` to target all devices (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", + "execute_all": true + } + +**Example request:** + +.. code-block:: shell + + curl -X POST \ + http://127.0.0.1:8000/api/v1/controller/batch-command/execute/ \ + -H 'authorization: Bearer yoursecretauthtoken' \ + -H 'content-type: application/json' \ + -d '{ + "organization": "org-uuid", + "type": "custom", + "input": {"command": "uptime"}, + "label": "Check uptime", + "execute_all": true + }' + +**Response:** ``201 Created`` with the batch command UUID. + +List Batch 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 Batch Command Detail +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + GET /api/v1/controller/batch-command/{id}/ + +Returns detailed information about a batch command, including the list of +targeted devices. + List Device Groups ~~~~~~~~~~~~~~~~~~ diff --git a/openwisp_controller/connection/admin.py b/openwisp_controller/connection/admin.py index f15c98d58..2409c6595 100644 --- a/openwisp_controller/connection/admin.py +++ b/openwisp_controller/connection/admin.py @@ -1,26 +1,31 @@ +import json from datetime import timedelta import reversion import swapper from django import forms from django.contrib import admin +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.http import HttpResponseForbidden, JsonResponse from django.urls import path, resolve -from django.utils.html import format_html +from django.utils.html import format_html, format_html_join +from django.utils.safestring import mark_safe from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ from openwisp_users.multitenancy import MultitenantOrgFilter -from openwisp_utils.admin import TimeReadonlyAdminMixin +from openwisp_utils.admin import ReadOnlyAdmin, TimeReadonlyAdminMixin from ..admin import MultitenantAdminMixin from ..config.admin import DeactivatedDeviceReadOnlyMixin, DeviceAdmin +from .filters import GroupFilter, LocationFilter from .schema import schema from .widgets import CommandSchemaWidget, CredentialsSchemaWidget Credentials = swapper.load_model("connection", "Credentials") DeviceConnection = swapper.load_model("connection", "DeviceConnection") Command = swapper.load_model("connection", "Command") +BatchCommand = swapper.load_model("connection", "BatchCommand") class CredentialsForm(forms.ModelForm): @@ -215,3 +220,244 @@ def schema_view(self, request): CommandInline, ] DeviceAdmin.add_reversion_following(follow=["deviceconnection_set"]) + + +class BatchCommandAdmin(MultitenantAdminMixin, ReadOnlyAdmin): + list_display = [ + "label", + "organization_display", + "colored_status", + "type", + "affected_devices", + "created", + ] + ordering = ("-created",) + list_filter = [ + MultitenantOrgFilter, + "status", + "type", + GroupFilter, + LocationFilter, + ] + list_select_related = ("organization",) + search_fields = [ + "label", + "notes", + "organization__name", + "devices__name", + "location__name", + "group__name", + ] + change_form_template = ( + "admin/connection/batch_command/batch_command_change_form.html" + ) + device_commands_per_page = 20 + exclude = ("devices",) + fields = [ + "organization_display", + "label", + "notes", + "colored_status", + "type", + "formatted_input", + "affected_devices", + "group", + "location", + "display_skipped_devices", + "created", + "modified", + ] + readonly_fields = [ + "organization_display", + "colored_status", + "type", + "formatted_input", + "affected_devices", + "display_skipped_devices", + "group", + "location", + "created", + "modified", + ] + + class Media: + css = { + "all": [ + "admin/css/changelists.css", + "admin/css/ow-filters.css", + "connection/css/batch-command.css", + ] + } + + def get_readonly_fields(self, request, obj=None): + fields = super().get_readonly_fields(request, obj) + return fields + list(self.__class__.readonly_fields) + + def _get_commands(self, request, obj): + qs = Command.objects.filter(batch_command=obj).select_related("device") + if not request.user.is_superuser: + qs = qs.filter( + device__organization_id__in=request.user.organizations_managed + ) + return qs + + def organization_display(self, obj): + if obj.organization: + return obj.organization.name + return _("All") + + organization_display.short_description = _("organization") + organization_display.admin_order_field = "organization" + + def colored_status(self, obj): + css_class = f"command-status {obj.status}" + return format_html( + '{1}', + css_class, + obj.get_status_display(), + ) + + colored_status.short_description = _("status") + + def formatted_input(self, obj): + if not obj.input: + return "-" + return obj.input.get("command", obj.input) + + formatted_input.short_description = _("input") + + def affected_devices(self, obj): + return obj.affected_devices + + affected_devices.short_description = _("affected devices") + + def display_skipped_devices(self, obj): + if not obj.skipped_devices: + return "-" + Device = swapper.load_model("config", "Device") + pks = list(obj.skipped_devices.keys()) + devices = {str(d.pk): d for d in Device.objects.filter(pk__in=pks)} + count = len(pks) + lines = [str(count)] + for pk_str, errors in obj.skipped_devices.items(): + device = devices.get(pk_str) + name = device.name if device else _("Deleted ({})").format(pk_str) + lines.append(format_html("{}: {}", name, ", ".join(errors))) + return format_html( + '
{}
', + format_html_join(mark_safe("
"), "{}", ((line,) for line in lines)), + ) + + display_skipped_devices.short_description = _("Skipped devices") + + def _build_filter_specs(self, request, obj, current_status): + filter_specs = [] + params = request.GET.copy() + + def _make_choice(current_value, display, param_name, value): + q = params.copy() + q.pop(param_name, None) + if value: + q[param_name] = value + qs = q.urlencode() + query_string = f"?{qs}" if qs else "" + return { + "display": display, + "selected": current_value == value, + "query_string": query_string, + } + + status_choices = [] + for status_value, display_name in ( + (("", _("All")),) + Command.STATUS_CHOICES + (("skipped", _("Skipped")),) + ): + status_choices.append( + _make_choice(current_status, display_name, "status", status_value) + ) + + class StatusFilter: + title = _("status") + choices = status_choices + + filter_specs.append(StatusFilter()) + return filter_specs + + def _paginate_commands(self, items, page_param, per_page=None): + per_page = per_page or self.device_commands_per_page + paginator = Paginator(list(items), per_page) + page_number = page_param or 1 + try: + page_obj = paginator.page(page_number) + except (PageNotAnInteger, EmptyPage): + page_obj = paginator.page(1) + return page_obj, paginator, page_obj.object_list + + def change_view(self, request, object_id, form_url="", extra_context=None): + extra_context = extra_context or {} + obj = self.get_object(request, object_id) + if obj: + Device = swapper.load_model("config", "Device") + commands_qs = self._get_commands(request, obj) + search_query = request.GET.get("q", "") + if search_query: + commands_qs = commands_qs.filter(device__name__icontains=search_query) + current_status = request.GET.get("status", "") + if current_status and current_status != "skipped": + commands_qs = commands_qs.filter(status=current_status) + rows = [] + for cmd in commands_qs: + rows.append( + { + "device_name": cmd.device.name, + "device_pk": cmd.device.pk, + "status": cmd.status, + "status_display": cmd.get_status_display(), + "output": (cmd.output or "").lstrip(), + "created": cmd.created, + "is_skipped": False, + } + ) + if obj.skipped_devices and current_status in ("", "skipped"): + pks = list(obj.skipped_devices.keys()) + devices = {str(d.pk): d for d in Device.objects.filter(pk__in=pks)} + for pk_str, errors in obj.skipped_devices.items(): + device = devices.get(pk_str) + name = device.name if device else _("Deleted ({})").format(pk_str) + if search_query and search_query.lower() not in name.lower(): + continue + rows.append( + { + "device_name": name, + "device_pk": pk_str, + "status": "skipped", + "status_display": _("Skipped"), + "output": ", ".join(errors), + "created": None, + "is_skipped": True, + } + ) + + def _sort_key(row): + priority = {"success": 0, "failed": 1, "skipped": 2} + return (priority.get(row["status"], 99), row["device_name"].lower()) + + rows.sort(key=_sort_key) + filter_specs = self._build_filter_specs(request, obj, current_status) + page_obj, paginator, commands = self._paginate_commands( + rows, request.GET.get("page", 1) + ) + extra_context.update( + { + "commands": commands, + "page_obj": page_obj, + "paginator": paginator, + "filter_specs": filter_specs, + "has_active_filters": any( + request.GET.get(param) for param in ["status"] + ), + } + ) + return super().change_view(request, object_id, extra_context=extra_context) + + +admin.site.register(BatchCommand, BatchCommandAdmin) diff --git a/openwisp_controller/connection/api/serializers.py b/openwisp_controller/connection/api/serializers.py index 142c1c30a..82b67ffc3 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,117 @@ class Meta: "is_working": {"read_only": True}, } read_only_fields = ("created", "modified") + + +class BatchCommandExecuteSerializer( + FilterSerializerByOrgManaged, serializers.ModelSerializer +): + 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"), + ) + execute_all = serializers.BooleanField(required=False, default=True) + + def __init__(self, *args, dry_run=False, **kwargs): + super().__init__(*args, **kwargs) + if dry_run: + self._skip_target_validation = True + self.fields["type"].required = False + self.fields["label"].required = False + else: + self._skip_target_validation = False + + class Meta: + model = BatchCommand + fields = ( + "organization", + "type", + "input", + "label", + "notes", + "devices", + "group", + "location", + "execute_all", + ) + extra_kwargs = { + "organization": {"required": False, "allow_null": True}, + } + + def validate(self, data): + org = data.get("organization") + execute_all = data.get("execute_all", False) + devices = data.get("devices") + group = data.get("group") + location = data.get("location") + if not org and not self.context["request"].user.is_superuser: + raise serializers.ValidationError( + _("Only superusers can execute batch commands without an organization.") + ) + if not self._skip_target_validation: + if ( + not execute_all + and not org + and not devices + and not group + and not location + ): + raise serializers.ValidationError( + _( + "Specify at least one targeting option " + "or set execute_all to true." + ) + ) + if devices: + for device in devices: + if org and device.organization_id != org.id: + raise serializers.ValidationError( + { + "devices": _( + "All devices must belong to the same organization." + ) + } + ) + return data + + +class BatchCommandSerializer(BaseSerializer): + device_count = serializers.IntegerField(read_only=True) + skipped_devices = serializers.JSONField(read_only=True) + + 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 4ec3e70ab..4a94d171d 100644 --- a/openwisp_controller/connection/api/urls.py +++ b/openwisp_controller/connection/api/urls.py @@ -40,6 +40,21 @@ def get_api_urls(api_views): api_views.deviceconnection_detail_view, name="deviceconnection_detail", ), + path( + "api/v1/controller/batch-command/", + api_views.batch_command_list_view, + name="batch_command_list", + ), + path( + "api/v1/controller/batch-command//", + api_views.batch_command_detail_view, + name="batch_command_detail", + ), + path( + "api/v1/controller/batch-command/execute/", + api_views.batch_command_execute_view, + name="batch_command_execute", + ), ] diff --git a/openwisp_controller/connection/api/views.py b/openwisp_controller/connection/api/views.py index 6af1270c7..41625ad68 100644 --- a/openwisp_controller/connection/api/views.py +++ b/openwisp_controller/connection/api/views.py @@ -1,12 +1,18 @@ +from django.core.exceptions import ValidationError +from django.db.models import Count from django.utils.translation import gettext_lazy as _ from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema +from rest_framework import status from rest_framework.generics import ( + GenericAPIView, + ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateDestroyAPIView, get_object_or_404, ) +from rest_framework.response import Response from swapper import load_model from openwisp_utils.api.pagination import OpenWispPagination @@ -17,6 +23,9 @@ RelatedDeviceProtectedAPIMixin, ) from .serializers import ( + BatchCommandDetailSerializer, + BatchCommandExecuteSerializer, + BatchCommandSerializer, CommandSerializer, CredentialSerializer, DeviceConnectionSerializer, @@ -26,6 +35,7 @@ Device = load_model("config", "Device") Credentials = load_model("connection", "Credentials") DeviceConnection = load_model("connection", "DeviceConnection") +BatchCommand = load_model("connection", "BatchCommand") class BaseCommandView(RelatedDeviceProtectedAPIMixin): @@ -138,6 +148,55 @@ class DeviceConnectionListCreateView(BaseDeviceConnection, ListCreateAPIView): DeviceConnenctionListCreateView = DeviceConnectionListCreateView +class BatchCommandExecuteView(ProtectedAPIMixin, GenericAPIView): + model = BatchCommand + queryset = BatchCommand.objects.all() + serializer_class = BatchCommandExecuteSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.validated_data.pop("execute_all", None) + 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=201) + + def get(self, request): + serializer = self.get_serializer( + data=request.query_params, + dry_run=True, + ) + serializer.is_valid(raise_exception=True) + serializer.validated_data.pop("execute_all", None) + try: + data = BatchCommand.dry_run(**serializer.validated_data) + except ValidationError as e: + return Response( + getattr(e, "message_dict", e.messages), + status=status.HTTP_400_BAD_REQUEST, + ) + data["devices"] = [str(d.pk) for d in data["devices"]] + return Response(data) + + +class BatchCommandListView(ProtectedAPIMixin, ListAPIView): + queryset = BatchCommand.objects.annotate(device_count=Count("devices")).order_by( + "-created" + ) + serializer_class = BatchCommandSerializer + pagination_class = OpenWispPagination + + +class BatchCommandDetailView(ProtectedAPIMixin, RetrieveAPIView): + queryset = BatchCommand.objects.annotate(device_count=Count("devices")) + serializer_class = BatchCommandDetailSerializer + + class DeviceConnectionDetailView(BaseDeviceConnection, RetrieveUpdateDestroyAPIView): def get_object(self): queryset = self.filter_queryset(self.get_queryset()) @@ -149,6 +208,9 @@ def get_object(self): return obj +batch_command_execute_view = BatchCommandExecuteView.as_view() +batch_command_list_view = BatchCommandListView.as_view() +batch_command_detail_view = BatchCommandDetailView.as_view() command_list_create_view = CommandListCreateView.as_view() command_details_view = CommandDetailsView.as_view() credential_list_create_view = CredentialListCreateView.as_view() diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index 17a06f7bb..aa680c76f 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -15,6 +15,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 @@ -30,7 +31,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__) @@ -467,6 +472,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") @@ -557,6 +569,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): @@ -719,3 +733,306 @@ 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")), + ) + + organization = models.ForeignKey( + get_model_name("openwisp_users", "Organization"), + on_delete=models.CASCADE, + blank=True, + null=True, + ) + status = models.CharField( + max_length=12, choices=STATUS_CHOICES, default=STATUS_CHOICES[0][0] + ) + type = models.CharField( + max_length=16, + choices=(COMMAND_CHOICES if django.VERSION < (5, 0) else get_command_choices), + ) + input = JSONField(blank=True, null=True, encoder=DjangoJSONEncoder) + group = models.ForeignKey( + get_model_name("config", "DeviceGroup"), + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("device group"), + ) + location = models.ForeignKey( + get_model_name("geo", "Location"), + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("location"), + ) + label = models.CharField( + max_length=64, + verbose_name=_("label"), + help_text=_("A short label to identify this batch command."), + ) + notes = models.TextField( + blank=True, + default="", + verbose_name=_("notes"), + help_text=_("Optional notes about this batch command."), + ) + devices = models.ManyToManyField( + get_model_name("config", "Device"), + blank=True, + verbose_name=_("devices"), + ) + skipped_devices = models.JSONField( + blank=True, + null=True, + default=dict, + verbose_name=_("Skipped devices"), + help_text=_( + "Maps device UUIDs to validation error messages for devices " + "that were skipped during command creation." + ), + ) + + class Meta: + abstract = True + verbose_name = _("Batch command") + verbose_name_plural = _("Batch commands") + + def __str__(self): + return self.label + + @cached_property + def total_devices(self): + return self.batch_commands.count() + len(self.skipped_devices or {}) + + @cached_property + def affected_devices(self): + return self.batch_commands.count() + + @property + def successful(self): + return self.batch_commands.filter(status="success").count() + + @property + def failed(self): + return self.batch_commands.filter(status="failed").count() + + @staticmethod + def _validate_device_org(device, organization_id): + if organization_id and device.organization_id != organization_id: + raise ValidationError( + { + "devices": _( + "All devices must belong to the same " + "organization as the batch command." + ) + } + ) + + def _validate_org_relations(self): + if not self.organization_id: + return + self._validate_org_relation("group", field_error="group") + self._validate_org_relation("location", field_error="location") + if self.pk and self.devices.exists(): + org_mismatch = self.devices.exclude(organization=self.organization).exists() + if org_mismatch: + raise ValidationError( + { + "devices": _( + "All devices must belong to the same " + "organization as the batch command." + ) + } + ) + + def clean(self): + super().clean() + self._validate_org_relations() + Command = load_model("connection", "Command") + allowed = dict( + Command.get_org_allowed_commands(organization_id=self.organization_id) + ) + if self.type not in allowed: + raise ValidationError( + { + "type": _( + '"{command}" command is not available for this organization' + ).format(command=self.type) + } + ) + try: + jsonschema.Draft4Validator(get_command_schema(self.type)).validate( + self.input + ) + except SchemaError as e: + raise ValidationError({"input": e.message}) + + def resolve_devices(self): + """ + Returns an iterator of devices targeted by this batch command, + resolved from explicit M2M devices or filtered by organization, + group, and location. Returns an empty iterator if no devices match. + """ + if self.pk and self.devices.exists(): + return self.devices.iterator() + Device = load_model("config", "Device") + qs = Device.objects.all() + if self.organization_id: + qs = qs.filter(organization=self.organization) + if self.group: + qs = qs.filter(group=self.group) + if self.location: + qs = qs.filter(devicelocation__location=self.location) + return qs.iterator() + + @classmethod + def execute(cls, **kwargs): + """ + Creates, validates, and persists the batch command, then schedules + execution via a background task. Raises ValidationError and deletes + the batch if no devices match the criteria. + """ + devices_list = kwargs.pop("devices", None) + batch = cls(**kwargs) + with transaction.atomic(): + batch.full_clean() + batch.save() + if devices_list: + batch.devices.set(devices_list) + batch._validate_org_relations() + if not batch.devices.exists(): + raise ValidationError( + _("No devices match the specified criteria."), + ) + elif not any(batch.resolve_devices()): + raise ValidationError( + _("No devices match the specified criteria."), + ) + batch.status = "in-progress" + batch.save(update_fields=["status"]) + 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() + if devices_list: + for device in devices_list: + cls._validate_device_org(device, batch.organization_id) + return {"devices": list(devices_list)} + return {"devices": list(batch.resolve_devices())} + + def create_commands(self): + """ + Creates individual Command instances for each device targeted by + this batch command. Returns early if commands already exist + (idempotent guard). Devices that fail validation are recorded + in skipped_devices and logged. + """ + if self.batch_commands.exists(): + return + Command = load_model("connection", "Command") + self.skipped_devices = {} + self.status = "in-progress" + self.save() + for device in self.resolve_devices(): + command = Command( + device=device, + type=self.type, + input=self.input, + batch_command=self, + ) + try: + # Validate before the atomic block so errors like + # ValidationError don't create/rollback + command.full_clean() + with transaction.atomic(): + command.save() + except Exception as e: + self.skipped_devices[str(device.pk)] = ( + e.messages if hasattr(e, "messages") else [str(e)] + ) + logger.warning( + "Skipping device %s for batch %s: %s", + device.pk, + self.pk, + e, + ) + if self.skipped_devices: + self.save(update_fields=["skipped_devices"]) + self.calculate_and_update_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. + """ + 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: + new_status = "idle" + elif stats["in_progress"] > 0: + new_status = "in-progress" + elif stats["failed"] > 0: + new_status = "failed" + elif ( + stats["successful"] > 0 and stats["completed"] == stats["total_operations"] + ): + new_status = "success" + else: + new_status = self.status + if self.status != new_status: + self.status = new_status + self.save(update_fields=["status"]) diff --git a/openwisp_controller/connection/filters.py b/openwisp_controller/connection/filters.py new file mode 100644 index 000000000..b0623c387 --- /dev/null +++ b/openwisp_controller/connection/filters.py @@ -0,0 +1,15 @@ +from django.utils.translation import gettext_lazy as _ + +from openwisp_users.multitenancy import MultitenantRelatedOrgFilter + + +class GroupFilter(MultitenantRelatedOrgFilter): + field_name = "group" + parameter_name = "group_id" + title = _("group") + + +class LocationFilter(MultitenantRelatedOrgFilter): + field_name = "location" + parameter_name = "location_id" + title = _("location") diff --git a/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py b/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py new file mode 100644 index 000000000..f45dbf74a --- /dev/null +++ b/openwisp_controller/connection/migrations/0011_batchcommand_command_batch_command.py @@ -0,0 +1,181 @@ +# Generated by Django 5.2.15 on 2026-06-23 13:40 + +import uuid + +import django +import django.core.serializers.json +import django.db.migrations.operations.special +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +from django.conf import settings +from django.db import migrations, models + +import openwisp_controller.connection.commands + +from . import assign_batchcommand_permissions_to_groups + + +class Migration(migrations.Migration): + + dependencies = [ + ("connection", "0010_replace_jsonfield_with_django_builtin"), + ("openwisp_users", "0024_apikey"), + migrations.swappable_dependency(settings.CONFIG_DEVICEGROUP_MODEL), + migrations.swappable_dependency(settings.CONFIG_DEVICE_MODEL), + migrations.swappable_dependency(settings.GEO_LOCATION_MODEL), + ("config", "0036_device_group"), + ] + + operations = [ + migrations.CreateModel( + name="BatchCommand", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("idle", "idle"), + ("in-progress", "in progress"), + ("success", "success"), + ("failed", "failed"), + ], + default="idle", + max_length=12, + ), + ), + ( + "type", + models.CharField( + choices=( + openwisp_controller.connection.commands.COMMAND_CHOICES + if django.VERSION < (5, 0) + else openwisp_controller.connection.commands.get_command_choices # noqa: E501 + ), + max_length=16, + ), + ), + ( + "input", + models.JSONField( + blank=True, + encoder=django.core.serializers.json.DjangoJSONEncoder, + null=True, + ), + ), + ( + "skipped_devices", + models.JSONField( + blank=True, + default=dict, + help_text=( + "Maps device UUIDs to validation error messages for " + "devices that were skipped during command creation." + ), + null=True, + verbose_name="Skipped devices", + ), + ), + ( + "devices", + models.ManyToManyField( + blank=True, + to=settings.CONFIG_DEVICE_MODEL, + verbose_name="devices", + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.CONFIG_DEVICEGROUP_MODEL, + verbose_name="device group", + ), + ), + ( + "location", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.GEO_LOCATION_MODEL, + verbose_name="location", + ), + ), + ( + "label", + models.CharField( + help_text=("A short label to identify this batch command."), + max_length=64, + verbose_name="label", + ), + ), + ( + "notes", + models.TextField( + blank=True, + default="", + verbose_name="notes", + help_text="Optional notes about this batch command.", + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="openwisp_users.organization", + ), + ), + ], + options={ + "verbose_name": "Batch command", + "verbose_name_plural": "Batch 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..cff9510f1 --- /dev/null +++ b/openwisp_controller/connection/static/connection/css/batch-command.css @@ -0,0 +1,144 @@ +#batchcommand_form .submit-row { + display: none; +} + +.commands-title { + font-size: 22px; + font-weight: 300; + margin: 0; + padding: 0; +} + +.search-section { + padding: 20px; +} + +.search-form { + display: flex; + align-items: center; +} + +#main #content .search-icon { + width: 20px; + height: 20px; + margin-right: 15px; + font-size: 16px; + margin-top: -3px; +} + +#main #content .search-input { + padding: 10px 15px; +} + +#main #content .search-button { + padding: 10px 20px; + margin-left: 15px; +} + +.filter-clear-link:hover { + color: var(--ow-color-fg-darker); +} + +.results-table { + width: 100%; + border-collapse: collapse; +} + +#main #content .device-link { + color: var(--ow-color-primary); + font-weight: bold; +} + +.device-name-disabled { + color: var(--body-quiet-color); + font-style: italic; +} + +.empty-results { + padding: 40px; + text-align: center; + color: var(--body-quiet-color); + font-style: italic; +} + +.pagination { + padding: 15px 20px; + text-align: right; + border-top: 2px solid var(--hairline-color); + background: var(--darkened-bg); +} + +.pagination a { + color: var(--body-fg); + text-decoration: none; + margin: 0 5px; +} + +.pagination .current-page { + margin: 0 10px; + color: var(--body-quiet-color); +} + +.paginator { + color: var(--body-quiet-color); + padding: 10px 20px; + border-bottom: 1px solid var(--hairline-color); + margin: 0; +} + +.command-status { + font-weight: bold; +} + +.command-status.success { + color: var(--ow-color-success); +} + +.command-status.failed { + color: var(--error-fg); +} + +.command-status.in-progress { + color: var(--body-quiet-color); +} + +.command-status.skipped { + color: var(--body-quiet-color); + opacity: 0.7; +} + +.command-output { + padding: 0; +} + +.command-output pre { + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + padding: 0; + font: inherit; + color: inherit; + background: transparent; +} + +.skipped-devices-list { + line-height: 1.7; +} + +.field-display_skipped_devices .readonly.readonly { + padding: 0; +} + +/* Adjustments for list filters */ +#main #content .left-arrow { + left: -1.125rem; +} +#main #content .right-arrow { + right: -1.125rem; +} +#main #content #ow-changelist-filter { + padding: 1.25rem 0rem; +} +#main #content .filters-top { + margin-bottom: 0.5rem; +} diff --git a/openwisp_controller/connection/tasks.py b/openwisp_controller/connection/tasks.py index d6ea2828b..22574e6ed 100644 --- a/openwisp_controller/connection/tasks.py +++ b/openwisp_controller/connection/tasks.py @@ -98,6 +98,24 @@ def launch_command(command_id): command._save_without_resurrecting() +@shared_task(bind=True) +def launch_batch_command(self, batch_id): + BatchCommand = load_model("connection", "BatchCommand") + try: + batch = BatchCommand.objects.get(pk=batch_id) + except BatchCommand.DoesNotExist: + logger.warning(f"The BatchCommand object with id {batch_id} has been deleted") + return + try: + batch.create_commands() + except Exception as e: + batch.status = "failed" + batch.save(update_fields=["status"]) + logger.exception( + f"An exception was raised while executing batch " f"command {batch_id}: {e}" + ) + + @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..43ae2a83c --- /dev/null +++ b/openwisp_controller/connection/templates/admin/connection/batch_command/batch_command_change_form.html @@ -0,0 +1,173 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static admin_list ow_tags %} + +{% block extrahead %} +{{ block.super }} + + + +{% endblock %} + +{% block content %} +{{ block.super }} + + +

{% trans "Commands" %}

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

{% trans 'Filter' %}

+
+ {% if has_active_filters %} +

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

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

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

+ {% endif %} + + + {% if page_obj.has_other_pages %} + + {% endif %} +
+{% endblock %} + +{% block footer %} +{{ block.super }} + +{% endblock %} diff --git a/openwisp_controller/connection/tests/pytest.py b/openwisp_controller/connection/tests/pytest.py index 220604cef..d7f7f5659 100644 --- a/openwisp_controller/connection/tests/pytest.py +++ b/openwisp_controller/connection/tests/pytest.py @@ -65,6 +65,7 @@ def _get_expected_response(self, command): "output": command.output, "device": str(command.device_id), "connection": str(command.connection_id), + "batch_command": None, }, } diff --git a/openwisp_controller/connection/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py index 6e3a827ab..f52001af7 100644 --- a/openwisp_controller/connection/tests/test_api.py +++ b/openwisp_controller/connection/tests/test_api.py @@ -15,15 +15,19 @@ from openwisp_users.tests.test_api import AuthenticationMixin from .. import settings as app_settings -from ..api.views import CommandListCreateView +from ..api.views import BatchCommandListView, CommandListCreateView from ..commands import ORGANIZATION_ENABLED_COMMANDS from .utils import CreateCommandMixin, CreateConnectionsMixin Command = load_model("connection", "Command") DeviceConnection = load_model("connection", "DeviceConnection") +BatchCommand = load_model("connection", "BatchCommand") command_qs = Command.objects.order_by("-created") OrganizationUser = load_model("openwisp_users", "OrganizationUser") Group = load_model("openwisp_users", "Group") +DeviceGroup = load_model("config", "DeviceGroup") +Location = load_model("geo", "Location") +DeviceLocation = load_model("geo", "DeviceLocation") class TestCommandsAPI(TestCase, AuthenticationMixin, CreateCommandMixin): @@ -860,3 +864,1149 @@ def test_deviceconnection_unauthenticated_user(self): "delete": 401, }, ) + + +class TestBatchCommandsAPI( + TestAdminMixin, AuthenticationMixin, TestCase, CreateConnectionsMixin +): + url_namespace = "connection_api" + + def setUp(self): + super().setUp() + self._login() + + def _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 + + def test_batch_command_list(self): + org = self._get_org() + url = reverse("connection_api:batch_command_list") + for _ in range(3): + self._create_batch_command(organization=org) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 3) + self.assertEqual(len(response.data["results"]), 3) + created_list = [cmd["created"] for cmd in response.data["results"]] + sorted_created = sorted(created_list, reverse=True) + self.assertEqual(created_list, sorted_created) + result = response.data["results"][0] + self.assertIn("id", result) + self.assertIn("status", result) + self.assertIn("type", result) + self.assertIn("input", result) + self.assertIn("device_count", result) + self.assertIn("created", result) + self.assertEqual(result["device_count"], 0) + + with patch.object(BatchCommandListView, "pagination_page_size", 2, create=True): + with self.subTest("pagination page 1"): + for _ in range(2): + self._create_batch_command(organization=org) + response = self.client.get(url + "?page=1") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 2) + + with self.subTest("pagination page 2"): + response = self.client.get(url + "?page=2") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 2) + + with self.subTest("pagination page 3 (partial)"): + response = self.client.get(url + "?page=3") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + + def test_batch_command_detail(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + batch = self._create_batch_command(organization=org, devices=[device]) + url = reverse("connection_api:batch_command_detail", args=[batch.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], str(batch.pk)) + self.assertEqual(response.data["status"], batch.status) + self.assertEqual(response.data["type"], batch.type) + self.assertEqual(response.data["input"], batch.input) + self.assertIn("devices", response.data) + self.assertEqual(response.data["devices"], [str(device.pk)]) + self.assertEqual(response.data["device_count"], 1) + + def test_batch_command_dry_run_endpoint(self): + org = self._get_org() + device1 = self._create_device( + name="dryf-dev1", + mac_address="00:11:22:33:44:d1", + organization=org, + ) + self._create_config(device=device1) + device2 = self._create_device( + name="dryf-dev2", + mac_address="00:11:22:33:44:d2", + organization=org, + ) + self._create_config(device=device2) + group = DeviceGroup.objects.create(name="dryf-group", organization=org) + device1.group = group + device1.save() + location = Location.objects.create( + name="dryf-loc", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device2, location=location) + + base_url = reverse("connection_api:batch_command_execute") + + with self.subTest("dry run with explicit devices"): + url = "{0}?organization={1}&devices={2}".format( + base_url, + str(org.pk), + str(device1.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["devices"], [str(device1.pk)]) + + with self.subTest("dry run with group"): + url = "{0}?organization={1}&group={2}".format( + base_url, + str(org.pk), + str(group.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertNotIn(str(device2.pk), response.data["devices"]) + + with self.subTest("dry run with location"): + url = "{0}?organization={1}&location={2}".format( + base_url, + str(org.pk), + str(location.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device2.pk), response.data["devices"]) + self.assertNotIn(str(device1.pk), response.data["devices"]) + + with self.subTest("dry run with group and location"): + DeviceLocation.objects.create(content_object=device1, location=location) + url = "{0}?organization={1}&group={2}&location={3}".format( + base_url, + str(org.pk), + str(group.pk), + str(location.pk), + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertNotIn(str(device2.pk), response.data["devices"]) + + with self.subTest("dry run org-wide"): + url = "{0}?organization={1}".format(base_url, str(org.pk)) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(str(device1.pk), response.data["devices"]) + self.assertIn(str(device2.pk), response.data["devices"]) + + 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"]) + batch.create_commands() + batch.refresh_from_db() + 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"]) + batch.create_commands() + batch.refresh_from_db() + 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"]) + batch.create_commands() + batch.refresh_from_db() + 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"]) + batch.create_commands() + batch.refresh_from_db() + 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", + "execute_all": True, + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + batch.create_commands() + batch.refresh_from_db() + self.assertEqual( + Command.objects.filter(batch_command=batch).count(), + 2, + ) + self.assertEqual(batch.skipped_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(17): + 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], + ) + + 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", + "execute_all": True, + } + 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", + "execute_all": True, + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + '"custom" command is not available for this organization', + response.data["type"][0], + ) + + def test_batch_command_no_org_only_allowed_to_superuser(self): + org = self._get_org() + self.client.logout() + operator = self._create_operator(organizations=[org]) + add_perm = Permission.objects.get(codename="add_batchcommand") + operator.user_permissions.add(add_perm) + self.client.force_login(operator) + payload = { + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "execute_all": True, + } + 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_batch_command_operator_endpoints_on_managed_org(self): + org = self._get_org() + self._create_credentials(name="op-cred", organization=org) + device = self._create_device( + name="op-dev", + mac_address="00:11:22:33:44:01", + organization=org, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + self.client.logout() + operator = self._create_operator(organizations=[org]) + operator.user_permissions.add( + Permission.objects.get(codename="add_batchcommand"), + Permission.objects.get(codename="view_batchcommand"), + ) + self.client.force_login(operator) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("execute"): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + with self.subTest("dry-run"): + response = self.client.get( + url, + data={"organization": str(org.pk)}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("devices", response.data) + + with self.subTest("execute_all"): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "execute_all": True, + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + def test_batch_command_administrator_endpoints_on_managed_org(self): + org = self._get_org() + self._create_credentials(name="admin-cred", organization=org) + device = self._create_device( + name="admin-dev", + mac_address="00:11:22:33:44:02", + organization=org, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + self.client.logout() + administrator = self._create_administrator(organizations=[org]) + self.client.force_login(administrator) + url = reverse("connection_api:batch_command_execute") + + with self.subTest("execute"): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + with self.subTest("dry-run"): + response = self.client.get( + url, + data={"organization": str(org.pk)}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("devices", response.data) + + with self.subTest("execute_all"): + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "execute_all": True, + } + 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_all"): + payload = { + "organization": str(org2.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "execute_all": True, + } + 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_all"): + payload = { + "organization": str(org2.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "execute_all": True, + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_batch_command_endpoints_organization_scoped(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + batch_org1 = self._create_batch_command(organization=org) + batch_org2 = self._create_batch_command(organization=org2) + self.client.logout() + operator = self._create_operator(organizations=[org]) + view_perm = Permission.objects.get(codename="view_batchcommand") + operator.user_permissions.add(view_perm) + self.client.force_login(operator) + + with self.subTest("list scoped to own org"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + + with self.subTest("detail cross-org"): + url = reverse( + "connection_api:batch_command_detail", + args=[batch_org2.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + with self.subTest("detail own org"): + url = reverse( + "connection_api:batch_command_detail", + args=[batch_org1.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_batch_command_endpoints_unauthorized(self): + self.client.logout() + execute_url = reverse("connection_api:batch_command_execute") + + with self.subTest("List"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + with self.subTest("Detail"): + url = reverse( + "connection_api:batch_command_detail", + args=[uuid.uuid4()], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + with self.subTest("Dry run"): + response = self.client.get(execute_url) + self.assertEqual(response.status_code, 401) + + with self.subTest("Execute"): + response = self.client.post( + execute_url, + data=json.dumps({"type": "custom"}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 401) + + def test_batch_command_bearer_authentication(self): + self.client.logout() + org = self._get_org() + batch = self._create_batch_command(organization=org) + device = self._create_device( + name="bearer-dev", + mac_address="00:11:22:33:44:be", + organization=org, + ) + self._create_config(device=device) + self._create_device_connection(device=device) + token = self._obtain_auth_token(username="admin", password="tester") + auth = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + + with self.subTest("list"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url, **auth) + self.assertEqual(response.status_code, 200) + self.assertIn("results", response.data) + + with self.subTest("detail"): + url = reverse( + "connection_api:batch_command_detail", + args=[batch.pk], + ) + response = self.client.get(url, **auth) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], str(batch.pk)) + + with self.subTest("dry-run"): + url = reverse("connection_api:batch_command_execute") + response = self.client.get( + url, + data={"organization": str(org.pk)}, + **auth, + ) + self.assertEqual(response.status_code, 200) + + with self.subTest("execute"): + url = reverse("connection_api:batch_command_execute") + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + **auth, + ) + self.assertEqual(response.status_code, 201) + self.assertIn("batch", response.data) + + def test_batch_command_detail_404(self): + org = self._get_org() + self._create_batch_command(organization=org) + url = reverse( + "connection_api:batch_command_detail", + args=[uuid.uuid4()], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_batch_command_endpoints_operator_different_org(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + batch_org1 = self._create_batch_command(organization=org) + self.client.logout() + operator = self._create_operator(organizations=[org2]) + self.client.force_login(operator) + + with self.subTest("list"): + url = reverse("connection_api:batch_command_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + + with self.subTest("detail"): + url = reverse( + "connection_api:batch_command_detail", + args=[batch_org1.pk], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + with self.subTest("dry-run"): + url = reverse("connection_api:batch_command_execute") + response = self.client.get( + url, + data={"organization": str(org.pk)}, + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("execute"): + url = reverse("connection_api:batch_command_execute") + payload = { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "execute_all": True, + } + response = self.client.post( + url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_batch_command_execute_skipped_devices(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_a = self._create_device( + name="device-a", + mac_address="00:11:22:33:44:aa", + organization=org, + ) + self._create_config(device=device_a) + self._create_device_connection(device=device_a) + device_b = self._create_device( + name="device-b", + mac_address="00:11:22:33:44:bb", + organization=org2, + ) + self._create_config(device=device_b) + self._create_device_connection(device=device_b) + execute_url = reverse("connection_api:batch_command_execute") + + with patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(org2.pk): ("reboot",)}, + ): + payload = { + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device_a.pk), str(device_b.pk)], + } + response = self.client.post( + execute_url, + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + # transaction.on_commit doesn't fire in TestCase; + # trigger create_commands() manually + batch.create_commands() + batch.refresh_from_db() + self.assertIn(str(device_b.pk), batch.skipped_devices) + self.assertIn( + '"custom" command is not available for this organization', + batch.skipped_devices[str(device_b.pk)][0], + ) + 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("skipped: no credentials"): + device = self._create_device( + name="skip-nc-dev", + mac_address="00:11:22:33:44:a1", + organization=org, + ) + self._create_config(device=device) + response = self.client.post( + execute_url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + batch.create_commands() + batch.refresh_from_db() + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertIn( + "Device has no credentials assigned", + batch.skipped_devices[str(device.pk)][0], + ) + detail_url = reverse( + "connection_api:batch_command_detail", + args=[batch.pk], + ) + detail_response = self.client.get(detail_url) + self.assertEqual(detail_response.status_code, 200) + self.assertIn(str(device.pk), detail_response.data["skipped_devices"]) + + with self.subTest("skipped: deactivated device"): + device = self._create_device( + name="skip-dd-dev", + mac_address="00:11:22:33:44:a2", + organization=org, + ) + self._create_config(device=device) + dd_cred = self._create_credentials( + name="skip-dd-cred", + organization=org, + ) + self._create_device_connection(device=device, credentials=dd_cred) + device.deactivate() + response = self.client.post( + execute_url, + data=json.dumps( + { + "organization": str(org.pk), + "type": "custom", + "input": {"command": "echo test"}, + "label": "test-label", + "devices": [str(device.pk)], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 201) + batch = BatchCommand.objects.get(pk=response.data["batch"]) + batch.create_commands() + batch.refresh_from_db() + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertIn( + "Device is deactivated", + batch.skipped_devices[str(device.pk)][0], + ) + detail_url = reverse( + "connection_api:batch_command_detail", + args=[batch.pk], + ) + detail_response = self.client.get(detail_url) + self.assertEqual(detail_response.status_code, 200) + self.assertIn(str(device.pk), detail_response.data["skipped_devices"]) + + 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 Batch 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 Batch 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 Batch 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 Batch command " + "and the organization of the related location match." + ) + ] + }, + ) diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index e3ed6bb97..bd176f582 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -32,6 +32,10 @@ Group = load_model("openwisp_users", "Group") Organization = load_model("openwisp_users", "Organization") Command = load_model("connection", "Command") +BatchCommand = load_model("connection", "BatchCommand") +DeviceGroup = load_model("config", "DeviceGroup") +Location = load_model("geo", "Location") +DeviceLocation = load_model("geo", "DeviceLocation") _connect_path = "paramiko.SSHClient.connect" _exec_command_path = "paramiko.SSHClient.exec_command" @@ -460,14 +464,14 @@ def test_operator_group_permissions(self): permissions = group.permissions.filter( content_type__app_label=f"{self.app_label}" ) - self.assertEqual(permissions.count(), 6) + self.assertEqual(permissions.count(), 8) def test_administrator_group_permissions(self): group = Group.objects.get(name="Administrator") permissions = group.permissions.filter( content_type__app_label=f"{self.app_label}" ) - self.assertEqual(permissions.count(), 12) + self.assertEqual(permissions.count(), 16) def test_device_connection_set_connector(self): dc = self._create_device_connection() @@ -968,6 +972,797 @@ def test_command_multiple_connections(self, connect_mocked): command.refresh_from_db() self.assertIn(command.connection, [dc1, dc2]) + 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 + + def test_batch_command_str(self): + org = self._get_org() + batch = self._create_batch_command(organization=org) + self.assertEqual(str(batch), "test-label") + + def test_batch_command_total_devices_successful_failed(self): + org = self._get_org() + device1 = self._create_device(organization=org) + self._create_config(device=device1) + dc1 = self._create_device_connection(device=device1) + device2 = self._create_device( + name="device2", + mac_address="00:11:22:33:44:02", + organization=org, + ) + self._create_config(device=device2) + dc2 = self._create_device_connection( + device=device2, + credentials=self._create_credentials( + name="Test credentials 2", + organization=org, + ), + ) + batch = self._create_batch_command(organization=org) + Command.objects.create( + batch_command=batch, + device=device1, + connection=dc1, + type=batch.type, + input={"command": "echo test"}, + status="success", + ) + Command.objects.create( + batch_command=batch, + device=device2, + connection=dc2, + type=batch.type, + input={"command": "echo test"}, + status="failed", + ) + self.assertEqual(batch.total_devices, 2) + self.assertEqual(batch.successful, 1) + self.assertEqual(batch.failed, 1) + self.assertEqual( + batch.batch_commands.filter(status="success").first().device, device1 + ) + self.assertEqual( + batch.batch_commands.filter(status="failed").first().device, device2 + ) + self.assertFalse( + batch.batch_commands.filter(status="success", device=device2).exists() + ) + self.assertFalse( + batch.batch_commands.filter(status="failed", device=device1).exists() + ) + + def test_batch_command_clean_validation(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device(organization=org2) + + with self.subTest("devices from different org"): + batch = self._create_batch_command(organization=org) + batch.devices.add(device_org2) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("devices", ctx.exception.message_dict) + self.assertIn( + "must belong to the same organization", + ctx.exception.message_dict["devices"][0], + ) + + with self.subTest("invalid command type for org"): + with mock.patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(org.pk): ("reboot",)}, + ): + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("type", ctx.exception.message_dict) + self.assertIn( + "not available for this organization", + ctx.exception.message_dict["type"][0], + ) + + with self.subTest("invalid JSON schema"): + batch = BatchCommand( + organization=org, + type="change_password", + input="not_an_object", + label="test-label", + ) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("input", ctx.exception.message_dict) + + with self.subTest("group org mismatch"): + group = DeviceGroup.objects.create(name="test-group", organization=org2) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + group=group, + ) + with self.assertRaises(ValidationError) as ctx: + batch.clean() + self.assertIn("group", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Batch 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 Batch command " + "and the organization of the related location match", + ctx.exception.message_dict["location"][0], + ) + + def test_batch_command_create_commands_deactivated_device(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + + with self.subTest("deactivating device does not block create_commands"): + device._is_deactivated = True + device.save(update_fields=["_is_deactivated"]) + device.config.set_status_deactivating() + device = Device.objects.get(pk=device.pk) + batch = self._create_batch_command(organization=org) + batch.devices.add(device) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.skipped_devices, {}) + + with self.subTest("fully deactivated device skipped in create_commands"): + device.config.set_status_deactivated() + device = Device.objects.get(pk=device.pk) + batch = self._create_batch_command(organization=org) + batch.devices.add(device) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 0) + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertIn( + "Device is deactivated", + batch.skipped_devices[str(device.pk)][0], + ) + + def test_batch_command_create_commands_no_credentials(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + batch = self._create_batch_command( + organization=org, + devices=[device], + ) + batch.create_commands() + batch.refresh_from_db() + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertIn( + "Device has no credentials assigned", + batch.skipped_devices[str(device.pk)][0], + ) + + def test_batch_command_create_commands_skip_scenarios(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + + with self.subTest("org command not allowed"): + device_a = self._create_device( + name="device-a", + mac_address="00:11:22:33:44:aa", + organization=org, + ) + self._create_config(device=device_a) + self._create_device_connection(device=device_a) + device_b = self._create_device( + name="device-b", + mac_address="00:11:22:33:44:bb", + organization=org2, + ) + self._create_config(device=device_b) + self._create_device_connection(device=device_b) + with mock.patch.dict( + ORGANIZATION_ENABLED_COMMANDS, + {str(org2.pk): ("reboot",)}, + ): + batch = self._create_batch_command( + organization=org, + devices=[device_a, device_b], + ) + batch.create_commands() + batch.refresh_from_db() + self.assertIn(str(device_b.pk), batch.skipped_devices) + self.assertIn( + '"custom" command is not available for this organization', + batch.skipped_devices[str(device_b.pk)][0], + ) + db_batch = BatchCommand.objects.get(pk=batch.pk) + self.assertEqual(batch.skipped_devices, db_batch.skipped_devices) + command_qs = Command.objects.filter(batch_command=batch) + self.assertTrue(command_qs.filter(device=device_a).exists()) + self.assertFalse(command_qs.filter(device=device_b).exists()) + + with self.subTest("mixed skip scenario"): + device_ok = self._create_device( + name="device-ok", + mac_address="00:11:22:33:44:01", + organization=org, + ) + self._create_config(device=device_ok) + ok_cred = self._create_credentials(name="device-ok-cred", organization=org) + self._create_device_connection(device=device_ok, credentials=ok_cred) + device_no_creds = self._create_device( + name="device-no-creds", + mac_address="00:11:22:33:44:02", + organization=org, + ) + self._create_config(device=device_no_creds) + device_deactivated = self._create_device( + name="device-deactivated", + mac_address="00:11:22:33:44:03", + organization=org, + ) + self._create_config(device=device_deactivated) + dd_cred = self._create_credentials(name="device-dd-cred", organization=org) + self._create_device_connection( + device=device_deactivated, credentials=dd_cred + ) + device_deactivated.deactivate() + batch = self._create_batch_command( + organization=org, + devices=[device_ok, device_no_creds, device_deactivated], + ) + batch.create_commands() + batch.refresh_from_db() + command_qs = Command.objects.filter(batch_command=batch) + self.assertEqual(command_qs.count(), 1) + self.assertTrue(command_qs.filter(device=device_ok).exists()) + self.assertIn(str(device_no_creds.pk), batch.skipped_devices) + self.assertIn( + "Device has no credentials assigned", + batch.skipped_devices[str(device_no_creds.pk)][0], + ) + self.assertIn(str(device_deactivated.pk), batch.skipped_devices) + self.assertIn( + "Device is deactivated", + batch.skipped_devices[str(device_deactivated.pk)][0], + ) + self.assertNotIn(str(device_ok.pk), batch.skipped_devices) + db_batch = BatchCommand.objects.get(pk=batch.pk) + self.assertEqual(batch.skipped_devices, db_batch.skipped_devices) + + def test_batch_command_resolve_devices(self): + org = self._get_org() + device1 = self._create_device( + name="device1", + mac_address="00:11:22:33:44:01", + organization=org, + ) + device2 = self._create_device( + name="device2", + mac_address="00:11:22:33:44:02", + organization=org, + ) + + with self.subTest("explicit devices via M2M"): + batch = self._create_batch_command( + organization=org, + devices=[device1], + ) + resolved = list(batch.resolve_devices()) + self.assertEqual(resolved, [device1]) + + with self.subTest("organization-scoped (no explicit devices)"): + batch = self._create_batch_command(organization=org) + resolved = list(batch.resolve_devices()) + self.assertIn(device1, resolved) + self.assertIn(device2, resolved) + + with self.subTest("group filtering"): + group = DeviceGroup.objects.create(name="test-group", organization=org) + device1.group = group + device1.save() + batch = self._create_batch_command(organization=org, group=group) + resolved = list(batch.resolve_devices()) + self.assertIn(device1, resolved) + self.assertNotIn(device2, resolved) + + with self.subTest("location filtering"): + location = Location.objects.create( + name="test-location", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device2, location=location) + batch = self._create_batch_command(organization=org, location=location) + resolved = list(batch.resolve_devices()) + self.assertIn(device2, resolved) + self.assertNotIn(device1, resolved) + + with self.subTest("group and location combined"): + DeviceLocation.objects.create(content_object=device1, location=location) + batch = self._create_batch_command( + organization=org, + group=device1.group, + location=location, + ) + resolved = list(batch.resolve_devices()) + self.assertIn(device1, resolved) + self.assertNotIn(device2, resolved) + + with self.subTest("no devices match"): + empty_group = DeviceGroup.objects.create( + name="empty-group", + organization=org, + ) + batch = self._create_batch_command( + organization=org, + group=empty_group, + ) + resolved = list(batch.resolve_devices()) + self.assertEqual(resolved, []) + + def test_batch_command_dry_run_method(self): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + + with self.subTest("dry_run with type"): + result = BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + ) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry_run without type"): + result = BatchCommand.dry_run(organization=org) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry_run with explicit devices"): + result = BatchCommand.dry_run( + organization=org, + devices=[device], + ) + self.assertEqual(result["devices"], [device]) + + with self.subTest("dry_run with group"): + group = DeviceGroup.objects.create(name="dry-run-group", organization=org) + device.group = group + device.save() + result = BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + group=group, + ) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry_run with location"): + location = Location.objects.create( + name="dry-run-loc", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device, location=location) + result = BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + location=location, + ) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry_run with group and location"): + result = BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + group=group, + location=location, + ) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + + with self.subTest("dry run org-wide"): + device2 = self._create_device( + name="dry-org-dev2", + mac_address="00:11:22:33:44:77", + organization=org, + ) + result = BatchCommand.dry_run(organization=org) + self.assertIn("devices", result) + self.assertIn(device, result["devices"]) + self.assertIn(device2, result["devices"]) + + def test_batch_command_execute_method(self): + org = self._get_org() + empty_org = self._create_org(name="empty-org", slug="empty-org") + + with self.subTest("execute with no devices"): + with self.assertRaises(ValidationError) as ctx: + BatchCommand.execute( + organization=empty_org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + self.assertIn( + "No devices match", + str(ctx.exception), + ) + + cred = self._create_credentials( + name="exec-cred", + organization=org, + ) + device1 = self._create_device( + name="exec-dev1", + mac_address="00:11:22:33:44:e1", + organization=org, + ) + self._create_config(device=device1) + self._create_device_connection(device=device1, credentials=cred) + device2 = self._create_device( + name="exec-dev2", + mac_address="00:11:22:33:44:e2", + organization=org, + ) + self._create_config(device=device2) + self._create_device_connection(device=device2, credentials=cred) + group = DeviceGroup.objects.create(name="exec-group", organization=org) + device1.group = group + device1.save() + location = Location.objects.create( + name="exec-loc", + type="indoor", + organization=org, + ) + DeviceLocation.objects.create(content_object=device2, location=location) + + with self.subTest("execute with explicit devices"): + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + devices=[device1], + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.batch_commands.first().device, device1) + + with self.subTest("execute with group"): + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + group=group, + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.batch_commands.first().device, device1) + + with self.subTest("execute with location"): + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + location=location, + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.batch_commands.first().device, device2) + + with self.subTest("execute with group and location"): + DeviceLocation.objects.create(content_object=device1, location=location) + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + group=group, + location=location, + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(batch.batch_commands.first().device, device1) + + with self.subTest("execute org-wide"): + batch = BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.create_commands() + self.assertEqual(batch.batch_commands.count(), 2) + cmd_devices = [c.device for c in batch.batch_commands.all()] + self.assertIn(device1, cmd_devices) + self.assertIn(device2, cmd_devices) + + def test_batch_command_execute_org_mismatch(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device( + name="exec-mm-dev", + mac_address="00:11:22:33:44:99", + organization=org2, + ) + self._create_config(device=device_org2) + self._create_device_connection(device=device_org2) + + with self.subTest("device org mismatch"): + with self.assertRaises(ValidationError) as ctx: + BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + devices=[device_org2], + ) + self.assertIn("devices", ctx.exception.message_dict) + self.assertIn( + "must belong to the same organization", + ctx.exception.message_dict["devices"][0], + ) + + with self.subTest("group org mismatch"): + group_org2 = DeviceGroup.objects.create( + name="exec-mm-group", + organization=org2, + ) + with self.assertRaises(ValidationError) as ctx: + BatchCommand.execute( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + group=group_org2, + ) + self.assertIn("group", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Batch 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 Batch command " + "and the organization of the related location match", + ctx.exception.message_dict["location"][0], + ) + + def test_batch_command_dry_run_org_mismatch(self): + org = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + device_org2 = self._create_device( + name="dry-mm-dev", + mac_address="00:11:22:33:44:98", + organization=org2, + ) + + with self.subTest("device org mismatch"): + with self.assertRaises(ValidationError) as ctx: + BatchCommand.dry_run( + organization=org, + devices=[device_org2], + ) + self.assertIn("devices", ctx.exception.message_dict) + self.assertIn( + "must belong to the same organization", + ctx.exception.message_dict["devices"][0], + ) + + with self.subTest("group org mismatch"): + group_org2 = DeviceGroup.objects.create( + name="dry-mm-group", + organization=org2, + ) + with self.assertRaises(ValidationError) as ctx: + BatchCommand.dry_run( + organization=org, + type="custom", + input={"command": "echo test"}, + group=group_org2, + ) + self.assertIn("group", ctx.exception.message_dict) + self.assertIn( + "Please ensure that the organization of this Batch 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 Batch 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("no change shows no extra save"): + initial_modified = batch2.modified + batch2.calculate_and_update_status() + batch2.refresh_from_db() + self.assertEqual(batch2.modified, initial_modified) + + def test_batch_command_permissions(self): + ct = ContentType.objects.get_by_natural_key( + app_label=self.app_label, model="batchcommand" + ) + operator_group = Group.objects.get(name="Operator") + admin_group = Group.objects.get(name="Administrator") + operator_permissions = operator_group.permissions.filter(content_type=ct) + admin_permissions = admin_group.permissions.filter(content_type=ct) + + with self.subTest("operator permissions"): + self.assertEqual(operator_permissions.count(), 2) + self.assertTrue( + operator_permissions.filter(codename="add_batchcommand").exists() + ) + self.assertTrue( + operator_permissions.filter(codename="view_batchcommand").exists() + ) + + with self.subTest("administrator permissions"): + self.assertEqual(admin_permissions.count(), 4) + class TestModelsTransaction(BaseTestModels, TransactionTestCase): def _prepare_conf_object(self, organization=None): diff --git a/openwisp_controller/connection/tests/test_selenium.py b/openwisp_controller/connection/tests/test_selenium.py index e75af19d3..644b0154f 100644 --- a/openwisp_controller/connection/tests/test_selenium.py +++ b/openwisp_controller/connection/tests/test_selenium.py @@ -1,3 +1,5 @@ +from time import sleep + from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.test import tag from django.urls import reverse @@ -53,11 +55,9 @@ 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, diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py index 587c08209..68acf8b62 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -16,6 +16,7 @@ Command = load_model("connection", "Command") DeviceConnection = load_model("connection", "DeviceConnection") OrganizationConfigSettings = load_model("config", "OrganizationConfigSettings") +BatchCommand = load_model("connection", "BatchCommand") class TestTasks(CreateConnectionsMixin, TestCase): @@ -315,3 +316,165 @@ def _delete_then_raise(self): with mock.patch.object(Command, "execute", _delete_then_raise): tasks.launch_command(command.pk) self.assertFalse(Command.objects.filter(pk=command.pk).exists()) + + @mock.patch("logging.Logger.warning") + def test_launch_batch_command_skips_deleted_batch(self, mocked_warning): + batch_id = uuid.uuid4() + tasks.launch_batch_command(batch_id=batch_id) + mocked_warning.assert_called_with( + f"The BatchCommand object with id {batch_id} has been deleted" + ) + + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") + def test_launch_batch_command_creates_commands(self, mocked_delay): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo 'test'"}, + label="test-label", + ) + batch.full_clean() + batch.save() + batch.devices.set([device]) + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 1) + command = batch.batch_commands.first() + self.assertEqual(command.device, device) + self.assertEqual(command.type, batch.type) + self.assertEqual(command.status, "in-progress") + mocked_delay.assert_called_once_with(command.pk) + + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") + def test_launch_batch_command_creates_commands_for_multiple_devices( + self, mocked_delay + ): + org = self._get_org() + cred = self._create_credentials(organization=org, name="Multi device cred") + devices = [] + for i in range(2): + d = self._create_device( + name=f"task-dev-{i}", + mac_address=f"00:11:22:33:44:{i + 0x50:02x}", + organization=org, + ) + self._create_config(device=d) + self._create_device_connection(device=d, credentials=cred) + devices.append(d) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + batch.devices.set(devices) + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 2) + cmd_devices = [c.device for c in batch.batch_commands.all()] + for d in devices: + self.assertIn(d, cmd_devices) + self.assertEqual(batch.status, "in-progress") + self.assertEqual(mocked_delay.call_count, 2) + + @mock.patch( + "openwisp_controller.connection.base.models.AbstractBatchCommand" + ".create_commands", + side_effect=SoftTimeLimitExceeded(), + ) + def test_launch_batch_command_timeout(self, mocked_create_commands): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.status, "failed") + + @mock.patch( + "openwisp_controller.connection.base.models.AbstractBatchCommand" + ".create_commands", + side_effect=RuntimeError("test error"), + ) + def test_launch_batch_command_exception(self, mocked_create_commands): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + with redirect_stderr(StringIO()) as stderr: + tasks.launch_batch_command(batch_id=batch.pk) + self.assertIn( + f"An exception was raised while executing batch " f"command {batch.pk}", + stderr.getvalue(), + ) + batch.refresh_from_db() + self.assertEqual(batch.status, "failed") + + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") + def test_launch_batch_command_all_devices_skipped(self, mocked_delay): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + device.deactivate() + device.config.set_status_deactivated() + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + batch.devices.set([device]) + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 0) + self.assertIn(str(device.pk), batch.skipped_devices) + self.assertEqual(batch.status, "idle") + mocked_delay.assert_not_called() + + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") + def test_launch_batch_command_already_processed(self, mocked_delay): + org = self._get_org() + device = self._create_device(organization=org) + self._create_config(device=device) + self._create_device_connection(device=device) + batch = BatchCommand( + organization=org, + type="custom", + input={"command": "echo test"}, + label="test-label", + ) + batch.full_clean() + batch.save() + batch.devices.set([device]) + # First call — creates commands + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 1) + first_call_count = mocked_delay.call_count + # Second call — should be a no-op (idempotency guard) + tasks.launch_batch_command(batch_id=batch.pk) + batch.refresh_from_db() + self.assertEqual(batch.batch_commands.count(), 1) + self.assertEqual(mocked_delay.call_count, first_call_count) diff --git a/openwisp_controller/geo/estimated_location/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py index c037106dd..fee63ae94 100644 --- a/openwisp_controller/geo/estimated_location/tests/tests.py +++ b/openwisp_controller/geo/estimated_location/tests/tests.py @@ -566,7 +566,7 @@ def _verify_location_details(device, mocked_response): device2.save() # 3 queries related to notifications cleanup device2.refresh_from_db() - with self.assertNumQueries(15): + with self.assertNumQueries(16): 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..22d04a924 100644 --- a/tests/openwisp2/sample_connection/api/views.py +++ b/tests/openwisp2/sample_connection/api/views.py @@ -1,3 +1,12 @@ +from openwisp_controller.connection.api.views import ( + BatchCommandDetailView as BaseBatchCommandDetailView, +) +from openwisp_controller.connection.api.views import ( + BatchCommandExecuteView as BaseBatchCommandExecuteView, +) +from openwisp_controller.connection.api.views import ( + BatchCommandListView as BaseBatchCommandListView, +) from openwisp_controller.connection.api.views import ( CommandDetailsView as BaseCommandDetailsView, ) @@ -42,9 +51,24 @@ class DeviceConnectionDetailView(BaseDeviceConnectionDetailView): pass +class BatchCommandExecuteView(BaseBatchCommandExecuteView): + pass + + +class BatchCommandListView(BaseBatchCommandListView): + pass + + +class BatchCommandDetailView(BaseBatchCommandDetailView): + pass + + command_list_create_view = CommandListCreateView.as_view() command_details_view = CommandDetailsView.as_view() credential_list_create_view = CredentialListCreateView.as_view() credential_detail_view = CredentialDetailView.as_view() deviceconnection_list_create_view = DeviceConnectionListCreateView.as_view() deviceconnection_detail_view = DeviceConnectionDetailView.as_view() +batch_command_execute_view = BatchCommandExecuteView.as_view() +batch_command_list_view = BatchCommandListView.as_view() +batch_command_detail_view = BatchCommandDetailView.as_view() diff --git a/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py b/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py new file mode 100644 index 000000000..dd03617cc --- /dev/null +++ b/tests/openwisp2/sample_connection/migrations/0005_batchcommand_command_batch_command.py @@ -0,0 +1,176 @@ +# Generated by Django 5.2.15 on 2026-06-15 18:15 + +import uuid + +import django +import django.core.serializers.json +import django.db.migrations.operations.special +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +from django.db import migrations, models + +from openwisp_controller import connection as connection_config +from openwisp_controller.connection.migrations import ( + assign_batchcommand_permissions_to_groups, +) + + +class Migration(migrations.Migration): + + dependencies = [ + ("sample_config", "0009_replace_jsonfield_with_django_builtin"), + ("sample_connection", "0004_replace_jsonfield_with_django_builtin"), + ("sample_geo", "0005_organizationgeosettings"), + ("sample_users", "0005_user_expiration_date_user_user_active_expiry_idx"), + ] + + operations = [ + migrations.CreateModel( + name="BatchCommand", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("idle", "idle"), + ("in-progress", "in progress"), + ("success", "success"), + ("failed", "failed"), + ], + default="idle", + max_length=12, + ), + ), + ( + "type", + models.CharField( + max_length=16, + choices=( + connection_config.commands.COMMAND_CHOICES + if django.VERSION < (5, 0) + else connection_config.commands.get_command_choices + ), + ), + ), + ( + "input", + models.JSONField( + blank=True, + encoder=django.core.serializers.json.DjangoJSONEncoder, + null=True, + ), + ), + ( + "devices", + models.ManyToManyField( + blank=True, to="sample_config.device", verbose_name="devices" + ), + ), + ( + "skipped_devices", + models.JSONField( + blank=True, + null=True, + default=dict, + verbose_name="Skipped devices", + help_text=( + "Maps device UUIDs to validation error messages for " + "devices that were skipped during command creation." + ), + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="sample_config.devicegroup", + verbose_name="device group", + ), + ), + ( + "location", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="sample_geo.location", + verbose_name="location", + ), + ), + ( + "label", + models.CharField( + max_length=64, + verbose_name="label", + help_text="A short label to identify this batch command.", + ), + ), + ( + "notes", + models.TextField( + blank=True, + default="", + verbose_name="notes", + help_text="Optional notes about this batch command.", + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="sample_users.organization", + ), + ), + ], + options={ + "verbose_name": "Batch command", + "verbose_name_plural": "Batch commands", + "abstract": False, + }, + ), + migrations.AddField( + model_name="command", + name="batch_command", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="batch_commands", + to="sample_connection.batchcommand", + ), + ), + migrations.RunPython( + code=assign_batchcommand_permissions_to_groups, + reverse_code=django.db.migrations.operations.special.RunPython.noop, + ), + ] diff --git a/tests/openwisp2/sample_connection/models.py b/tests/openwisp2/sample_connection/models.py index 7964c5471..4e96065e8 100644 --- a/tests/openwisp2/sample_connection/models.py +++ b/tests/openwisp2/sample_connection/models.py @@ -1,6 +1,7 @@ from django.db import models from openwisp_controller.connection.base.models import ( + AbstractBatchCommand, AbstractCommand, AbstractCredentials, AbstractDeviceConnection, @@ -27,3 +28,8 @@ class Meta(AbstractDeviceConnection.Meta): class Command(AbstractCommand): class Meta(AbstractCommand.Meta): abstract = False + + +class BatchCommand(AbstractBatchCommand): + class Meta(AbstractBatchCommand.Meta): + abstract = False diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index c45eb5537..0e27adfde 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -293,6 +293,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" )