Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/user/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,115 @@ Get Command Details

GET /api/v1/controller/device/{device_id}/command/{command_id}/

.. _controller_batch_command_api:

@nemesifier nemesifier Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The REST API docs were added, but the related docs requested for this feature are still missing. Please also update docs/developer/extending.rst with CONNECTION_BATCHCOMMAND_MODEL, add a first basic version of a new mass commands subsection at the end of docs/user/shell-commands.rst linking to this REST API anchor, and mention mass commands in docs/user/intro.rst (linking to its dedicated subsection in shell-commands.rst.


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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If input is supported for GET dry-run, please show the exact query-string encoding for a JSON object and add test coverage for dry-run with both type and input. Otherwise, this should state that command input validation is only practical through the POST execute endpoint.

``devices`` Repeated ``devices`` query parameter, each a device UUID
(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 (**conditionally required** —
depends on command type)
``label`` A short label to identify this batch command
(**required**)
``notes`` Optional notes (optional)
``devices`` List of device UUIDs (optional)
``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' \

@nemesifier nemesifier Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This placeholder is locally consistent with the single-device command example, but the REST API page is inconsistent overall because it also uses hash-like literal tokens and <token>, let's settle on one style, please.

-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
~~~~~~~~~~~~~~~~~~

Expand Down
119 changes: 119 additions & 0 deletions openwisp_controller/connection/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question about the API contract: are we sure that accepting devices=[] and treating it the same as omitted devices is the intended behavior? Many clients interpret an empty list as an explicit empty selection. With an organization provided, this can become an all-devices target. If that is intentional, the docs should say so clearly. If not, the code should distinguish a missing devices key from an empty list.

pk_field=serializers.UUIDField(format="hex_verbose"),
)
execute_all = serializers.BooleanField(required=False, default=False)

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.")
)
Comment thread
dee077 marked this conversation as resolved.
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
Comment thread
dee077 marked this conversation as resolved.


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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For shared/global batch commands (organization=None), this can expose tenant-specific data to organization managers through the list endpoint. In particular, skipped_devices keys are device UUIDs and their messages may reveal details from organizations the user does not manage. Showing general shared batch metadata may be fine, but tenant-specific fields should be filtered or redacted. I added a failing regression test: TestBatchCommandsAPI.test_shared_batch_command_does_not_expose_other_tenant_data.

"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",)
15 changes: 15 additions & 0 deletions openwisp_controller/connection/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uuid:pk>/",
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",
),
]


Expand Down
62 changes: 62 additions & 0 deletions openwisp_controller/connection/api/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +23,9 @@
RelatedDeviceProtectedAPIMixin,
)
from .serializers import (
BatchCommandDetailSerializer,
BatchCommandExecuteSerializer,
BatchCommandSerializer,
CommandSerializer,
CredentialSerializer,
DeviceConnectionSerializer,
Expand All @@ -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):
Expand Down Expand Up @@ -138,6 +148,55 @@ class DeviceConnectionListCreateView(BaseDeviceConnection, ListCreateAPIView):
DeviceConnenctionListCreateView = DeviceConnectionListCreateView


class BatchCommandExecuteView(ProtectedAPIMixin, GenericAPIView):
Comment thread
dee077 marked this conversation as resolved.
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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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())
Expand All @@ -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()
Expand Down
Loading
Loading