Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c36342c
[feature] Added BatchCommand model for mass command execution #1344
dee077 Jun 9, 2026
a809fc7
[feature] Proof of concept need some code refinement
dee077 Jun 12, 2026
bc9b780
[feature] Refactored BatchCommand model, API, and task for mass comma…
dee077 Jun 14, 2026
2333066
[feature] Added model-level validation, failed Command records, and i…
dee077 Jun 15, 2026
b6df4db
[feature] Finalized BatchCommand migration and QA fixes
dee077 Jun 15, 2026
07d9239
[fix] Made GET dry-run work without type param, default execute_all t…
dee077 Jun 16, 2026
1f8c30e
[fix] Add docstrings and minor fixes
dee077 Jun 19, 2026
5d391a6
[fix] Restructure BatchCommand fields and refine test fixtures
dee077 Jun 20, 2026
cfe546c
[feature] Added skipped_devices model field and comprehensive batch c…
dee077 Jun 23, 2026
736004e
[fix] Migration fix and add skipped devices in BatchCommandSerializer
dee077 Jun 23, 2026
0b2fd35
[tests] Add more tests
dee077 Jun 25, 2026
134cd18
[fix] Address coderabbit comments and fix flaky tests and ci
dee077 Jun 25, 2026
a2353a5
[fix] Address comments
dee077 Jun 26, 2026
a58b585
[fix] Additional tests
dee077 Jun 28, 2026
76763f5
[feature] Add label and notes field in model
dee077 Jul 1, 2026
e800e88
[docs] Add docs for batch command endpoints
dee077 Jul 3, 2026
cce88e6
[fix] Docs, filter default and transaction tests
dee077 Jul 8, 2026
fababb3
[tests] Added failing test for cases that need to be addressed
nemesifier Jul 9, 2026
84c2dd3
[fix] Address comments
dee077 Jul 11, 2026
5f2277b
[chores] Update Batch to Mass for user facing sections and fix bugs
dee077 Jul 11, 2026
8efe276
[fix] Remove execute_all from payload and adjust tests according to it
dee077 Jul 15, 2026
455ff25
[fix] Update docs and address coderabbit comments
dee077 Jul 15, 2026
4994e34
[fix] Remove non superupser able to see org wide batch commands in re…
dee077 Jul 16, 2026
d62c23a
[fix] Set org if org=null and location/group is passed
dee077 Jul 17, 2026
8747b4a
[docs] Update docs
dee077 Jul 17, 2026
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
1 change: 1 addition & 0 deletions docs/developer/extending.rst
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ Once you have created the models, add the following to your
CONNECTION_CREDENTIALS_MODEL = "sample_connection.Credentials"
CONNECTION_DEVICECONNECTION_MODEL = "sample_connection.DeviceConnection"
CONNECTION_COMMAND_MODEL = "sample_connection.Command"
CONNECTION_BATCHCOMMAND_MODEL = "sample_connection.BatchCommand"
SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL = "sample_subnet_division.SubnetDivisionRule"
SUBNET_DIVISION_SUBNETDIVISIONINDEX_MODEL = "sample_subnet_division.SubnetDivisionIndex"

Expand Down
3 changes: 3 additions & 0 deletions docs/user/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ e.g.:

- Sending configuration updates.
- :doc:`Executing shell commands <shell-commands>`.
- :doc:`Executing mass commands <shell-commands>`: Run a command on
multiple devices at once, see the :ref:`batch command API
<controller_batch_command_api>` for details.
- Perform firmware upgrades via the additional :doc:`firmware upgrade
module </firmware-upgrader/index>`.

Expand Down
117 changes: 114 additions & 3 deletions docs/user/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ command type being executed.

curl -X POST \
http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/command/ \
-H 'authorization: Bearer yoursecretauthtoken' \
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
-H 'content-type: application/json' \
-d '{
"type": "custom",
Expand All @@ -481,6 +481,117 @@ Get Command Details

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

.. _controller_batch_command_api:
Comment thread
dee077 marked this conversation as resolved.

Dry-Run Mass Command
~~~~~~~~~~~~~~~~~~~~

.. code-block:: text

GET /api/v1/controller/batch-command/execute/

Returns the list of devices that would be targeted without executing
anything. Useful for previewing which devices are affected.

**Query Parameters:**

================ =========================================================
Parameter Description
================ =========================================================
``organization`` Organization UUID (optional for superusers; set
automatically when ``group`` or ``location`` is provided)
``type`` Command type (optional for dry-run)
``input`` JSON input data for the command (optional for dry-run).
Encode as a URL-encoded JSON object, e.g.
``?type=custom&input=%7B%22command%22%3A%22uptime%22%7D``
``devices`` Repeated ``devices`` query parameter, each a device UUID
(optional; when provided, ``group`` and ``location`` are
ignored)
``group`` Device group UUID (optional)
``location`` Location UUID (optional)
================ =========================================================

Execute a Mass Command
~~~~~~~~~~~~~~~~~~~~~~

.. code-block:: text

POST /api/v1/controller/batch-command/execute/

Creates and executes a batch command on the targeted devices.

**Request Parameters:**

================ =========================================================
Parameter Description
================ =========================================================
``organization`` Organization UUID (optional for superusers; set
automatically when ``group`` or ``location`` is provided)
``type`` Type of command to execute (**required**)
``input`` Input data for the command (**conditionally required** —
depends on command type)
``label`` A short label to identify this batch command
(**required**)
``notes`` Optional notes (optional)
``devices`` List of device UUIDs (optional; when provided, ``group``
and ``location`` are ignored)
``group`` Device group UUID (optional)
``location`` Location UUID (optional)
================ =========================================================

**Available Command Types:**

See :ref:`controller_execute_command_api` for available command types and
input formats.

**Example payload:**

.. code-block:: json

{
"organization": "org-uuid",
"type": "custom",
"input": {"command": "uptime"},
"label": "Check uptime"
}

**Example request:**

.. code-block:: shell

curl -X POST \
http://127.0.0.1:8000/api/v1/controller/batch-command/execute/ \
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
-H 'content-type: application/json' \
-d '{
"organization": "org-uuid",
"type": "custom",
"input": {"command": "uptime"},
"label": "Check uptime"
}'

**Response:** ``201 Created`` with the batch command UUID.

List Mass Commands
~~~~~~~~~~~~~~~~~~

.. code-block:: text

GET /api/v1/controller/batch-command/

Returns a paginated list of batch commands with device count and skipped
device information.

Get Mass Command Detail
~~~~~~~~~~~~~~~~~~~~~~~

.. code-block:: text

GET /api/v1/controller/batch-command/{id}/

Returns detailed information about a batch command, including the list of
targeted devices.

List Device Groups
~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -825,7 +936,7 @@ organization.

curl -X PUT \
'http://127.0.0.1:8000/api/v1/controller/organization/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/geo-settings/' \
-H 'authorization: Bearer <token>' \
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
-H 'content-type: application/json' \
-d '{"estimated_location_enabled": true}'

Expand All @@ -844,7 +955,7 @@ partial update to the resource at the same endpoint path.

curl -X PATCH \
'http://127.0.0.1:8000/api/v1/controller/organization/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/geo-settings/' \
-H 'authorization: Bearer <token>' \
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
-H 'content-type: application/json' \
-d '{"estimated_location_enabled": true}'

Expand Down
32 changes: 32 additions & 0 deletions docs/user/shell-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,35 @@ How to register or unregister commands

Refer to :ref:`registering_unregistering_commands` in the developer
documentation.

.. _mass_commands:

Mass Commands
-------------

Mass commands allow you to execute a command on multiple devices
simultaneously, rather than issuing commands one device at a time. This is
useful for rebooting all devices in a group, changing passwords across
multiple devices, or running diagnostics on all devices in an
organization.

**Targeting options:**

- ``devices``: Explicit list of device UUIDs.
- ``group``: Device group UUID.
- ``location``: Location UUID.

If ``devices`` is provided, ``group`` and ``location`` are ignored.
Otherwise, ``group`` and ``location`` can be used together to narrow the
target set within the organization.

If no targeting options are provided, the command targets all devices in
the organization. If ``devices`` is provided as an empty list, the request
is rejected because no devices match.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

For superusers, ``organization`` is set automatically when ``group`` or
``location`` is provided.

Refer to the :ref:`Batch Command API <controller_batch_command_api>`
documentation for the available endpoints, request parameters, and
examples.
113 changes: 113 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,111 @@ 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Screenshot from 2026-07-11 04-12-39

Made these work for above scenerios with execute_all and other possible options

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed execute_all instead to avoid these many scenarios

pk_field=serializers.UUIDField(format="hex_verbose"),
)

def __init__(self, *args, dry_run=False, **kwargs):
super().__init__(*args, **kwargs)
self.dry_run = dry_run
if dry_run:
self.fields["type"].required = False
self.fields["label"].required = False

class Meta:
model = BatchCommand
fields = (
"organization",
"type",
"input",
"label",
"notes",
"devices",
"group",
"location",
)
extra_kwargs = {
"organization": {"required": False, "allow_null": True},
}

def validate(self, data):
org = data.get("organization")
if not org and not self.context["request"].user.is_superuser:
raise serializers.ValidationError(
_("Only superusers can execute batch commands without an organization.")
)
Comment thread
dee077 marked this conversation as resolved.
# DRF's many=True injects [] for QueryDict even when key is
# absent; remove it so model can distinguish omitted vs explicit [].
if "devices" not in self.initial_data and "devices" in data:
data.pop("devices")
return data


class BatchCommandSerializer(BaseSerializer):
device_count = serializers.IntegerField(read_only=True)
skipped_devices = serializers.JSONField(read_only=True)
organization = serializers.PrimaryKeyRelatedField(
read_only=True,
pk_field=serializers.UUIDField(format="hex_verbose"),
)
group = serializers.PrimaryKeyRelatedField(
read_only=True,
allow_null=True,
pk_field=serializers.UUIDField(format="hex_verbose"),
)
location = serializers.PrimaryKeyRelatedField(
read_only=True,
allow_null=True,
pk_field=serializers.UUIDField(format="hex_verbose"),
)

def to_representation(self, instance):
data = super().to_representation(instance)
# The raw password is visible in API responses between
# when the batch is saved and when the Celery task runs _clean_sensitive_info().
if instance.type == "change_password":
data["input"] = {"password": "********"}
return data

class Meta:
model = BatchCommand
fields = (
"id",
"organization",
"status",
"type",
"input",
"label",
"notes",
"group",
"location",
"device_count",
"skipped_devices",

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated!
Now it will show
"Org A device": ""restricted to {org_name} managers and users"
instead of just not showing an entry for skipped devices

"created",
"modified",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Loading
Loading