diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6c68b43c7..b5677971b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,9 +73,9 @@ jobs:
pip install -U ${{ matrix.django-version }}
sudo npm install -g prettier
- - name: Start InfluxDB and redis
+ - name: Start redis
if: ${{ !cancelled() && steps.deps.conclusion == 'success' }}
- run: docker compose up -d postgres redis
+ run: docker compose up -d redis
- name: QA checks
run: |
@@ -83,11 +83,7 @@ jobs:
- name: Tests
if: ${{ !cancelled() && steps.deps.conclusion == 'success' }}
- run: |
- coverage run runtests.py --parallel
- SAMPLE_APP=1 coverage run ./runtests.py --parallel --exclude-tag=selenium_tests
- coverage combine
- coverage xml
+ run: ./runtests
env:
SELENIUM_HEADLESS: 1
GECKO_LOG: 1
diff --git a/.gitignore b/.gitignore
index fe98efe8f..858291276 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,7 @@ coverage.xml
tests/openwisp2/media/
tests/openwisp2/private/
!tests/openwisp2/private/firmware/fake-img*.bin
+tests/openwisp2/firmware/*
# Sphinx documentation
docs/_build/
diff --git a/docker-compose.yml b/docker-compose.yml
index 1424fbfd2..b3ec96001 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,17 +1,6 @@
-version: "3"
-
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
entrypoint: redis-server --appendonly yes
-
- postgres:
- image: postgis/postgis:15-3.4-alpine
- environment:
- POSTGRES_PASSWORD: openwisp2
- POSTGRES_USER: openwisp2
- POSTGRES_DB: openwisp2
- ports:
- - 5432:5432
diff --git a/docs/developer/index.rst b/docs/developer/index.rst
index e7998462c..a22193797 100644
--- a/docs/developer/index.rst
+++ b/docs/developer/index.rst
@@ -8,6 +8,7 @@ Developer Docs
./installation.rst
./extending.rst
+ ./utils.rst
Other useful resources:
diff --git a/docs/developer/installation.rst b/docs/developer/installation.rst
index 58ba66ad1..95cc088d1 100644
--- a/docs/developer/installation.rst
+++ b/docs/developer/installation.rst
@@ -41,11 +41,11 @@ Navigate into the cloned repository:
.. _firmware_upgrader_dev_docker:
-Launch Redis and PostgreSQL:
+Launch Redis:
.. code-block:: shell
- docker compose up -d redis postgres
+ docker compose up -d redis
Setup and activate a virtual-environment (we'll be using `virtualenv
`_):
@@ -99,7 +99,7 @@ windows are needed):
celery -A openwisp2 worker -l info
celery -A openwisp2 beat -l info
-Run quality assurance tests with:
+Run QA checks with:
.. code-block:: shell
@@ -110,31 +110,16 @@ Run tests with (make sure you have the :ref:`selenium dependencies
.. code-block:: shell
- # standard tests
- ./runtests.py
+ ./runtests
-Some tests, such as the Selenium UI tests, require a PostgreSQL database
-to run. If you don't have a PostgreSQL database running on your system,
-you can use the :ref:`Docker Compose configuration provided in this
-repository `. Once set up, you can run these
-specific tests as follows:
+``./runtests`` is a wrapper that runs tests for both
+``openwisp_firmware_upgrader`` and the SAMPLE_APP. To run only the
+SAMPLE_APP tests:
.. code-block:: shell
- # Run only specific selenium tests classes
- cd tests/
- DJANGO_SETTINGS_MODULE=openwisp2.postgresql_settings ./manage.py test openwisp_firmware_upgrader.tests.test_selenium.TestDeviceAdmin
-
- # tests for the sample app
SAMPLE_APP=1 ./runtests.py --keepdb --failfast
-When running the last line of the previous example, the environment
-variable ``SAMPLE_APP`` activates the app in
-``/tests/openwisp2/sample_firmware_upgrader/`` which is a simple django
-app that extends ``openwisp-firmware-upgrader`` with the sole purpose of
-testing its extensibility, for more information regarding this concept,
-read :doc:`extending`.
-
.. important::
If you want to add ``openwisp-firmware-upgrader`` to an existing
diff --git a/docs/developer/utils.rst b/docs/developer/utils.rst
new file mode 100644
index 000000000..3bf175fc9
--- /dev/null
+++ b/docs/developer/utils.rst
@@ -0,0 +1,31 @@
+Code Utilities
+==============
+
+.. include:: ../partials/developer-docs.rst
+
+.. contents:: **Table of Contents**:
+ :depth: 2
+ :local:
+
+Signals
+-------
+
+.. include:: /partials/signals-note.rst
+
+``firmware_upgrader_log_updated``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+**Path**:
+``openwisp_firmware_upgrader.signals.firmware_upgrader_log_updated``
+
+**Arguments**:
+
+- ``sender``: the model class that sent the signal (``UpgradeOperation``)
+- ``instance``: instance of ``UpgradeOperation`` which got its log updated
+- ``line``: the new log line that was appended
+- ``**kwargs``: additional keyword arguments
+
+This signal is emitted when the log content of an upgrade operation is
+updated. You can use this signal to perform custom actions when log
+updates occur, such as sending notifications, updating external systems,
+or logging to custom destinations.
diff --git a/docs/index.rst b/docs/index.rst
index e98180f02..8b27215c8 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -38,9 +38,11 @@ within the OpenWISP architecture.
./user/intro.rst
./user/quickstart.rst
+ ./user/upgrade-status.rst
./user/automatic-device-firmware-detection.rst
./user/custom-firmware-upgrader.rst
./user/rest-api.rst
+ ./user/websocket-api.rst
./user/settings.rst
.. toctree::
diff --git a/docs/user/intro.rst b/docs/user/intro.rst
index 0d06c915e..bf533d924 100644
--- a/docs/user/intro.rst
+++ b/docs/user/intro.rst
@@ -9,7 +9,8 @@ Firmware Upgrader: Features
or not
- Prevents accidental multiple upgrades using the same firmware image
- Single device upgrade
-- Mass upgrades
+- Mass upgrades with possibility of filtering by device group and/or
+ geographic location
- Possibility to divide firmware images in categories
- :doc:`REST API `
- :doc:`Possibility of writing custom upgraders
diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst
index 907ce87e4..50854b79e 100644
--- a/docs/user/quickstart.rst
+++ b/docs/user/quickstart.rst
@@ -98,8 +98,8 @@ it.
4. Perform a Firmware Upgrade to a Specific Device
--------------------------------------------------
-.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/quickstart-devicefirmware.gif
- :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/quickstart-devicefirmware.gif
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-devicefirmware.gif
+ :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-devicefirmware.gif
Once a new build is ready, has been created in the system and its image
have been uploaded, it will be the time to finally upgrade our devices.
@@ -110,15 +110,39 @@ then go to the "Firmware" tab.
If you correctly filled **OS identifier** in step 2, you should have a
situation similar to the one above: in this example, the device is using
version ``1.0`` and we want to upgrade it to version ``2.0``, once the new
-firmware image is selected we just have to hit save, then a new tab will
-appear in the device page which allows us to see what's going on during
-the upgrade.
+firmware image is selected we just have to hit save, then a new tab
+"Recent Firmware Upgrades" will appear in the device page which allows us
+to observe in real time what's going on during the upgrade without
+requiring page reloads.
-Right now, the update of the upgrade information is not asynchronous yet,
-so you will have to reload the page periodically to find new information.
-This will be addressed in a future release.
+5. Canceling a Firmware Upgrade
+-------------------------------
-5. Performing Mass Upgrades
+If you need to cancel a firmware upgrade that is currently in progress on
+a specific device, you can do so from the device's firmware upgrade page.
+
+To cancel an ongoing upgrade:
+
+- Navigate to the device details page
+- Go to the "Firmware" tab
+- If an upgrade is in progress, you will see a "Cancel Upgrade" button
+- Click the "Cancel Upgrade" button to stop the upgrade process
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-cancel-upgrade.gif
+ :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-cancel-upgrade.gif
+
+Cancellation is only possible during the early stages of the upgrade
+process. Once the firmware flashing begins, the cancel button will be
+disabled and the upgrade cannot be stopped, as interrupting the flashing
+process could corrupt the device.
+
+The system automatically prevents cancellation during critical phases to
+ensure device safety.
+
+Once cancelled, the upgrade status will be updated to show that the
+operation was cancelled, and you can attempt a new upgrade if needed.
+
+6. Performing Mass Upgrades
---------------------------
Before proceeding, please ensure the following preconditions are met:
@@ -134,16 +158,24 @@ At this stage you can try a mass upgrade by doing the following:
devices to be upgraded with
- click on "Mass-upgrade devices related to the selected build".
-.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/quickstart-batch-upgrade.gif
- :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/quickstart-batch-upgrade.gif
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-batch-upgrade.gif
+ :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-batch-upgrade.gif
At this point you should see a summary page which will inform you of which
-devices are going to be upgraded, you can either confirm the operation or
-cancel.
+devices are going to be upgraded. On this page, you can optionally filter
+the devices to be upgraded by:
-Once the operation is confirmed you will be redirected to a page in which
-you can monitor the progress of the upgrade operations.
+- **Device Group**: limit the upgrade to devices belonging to a specific
+ group
+- **Geographic Location**: limit the upgrade to devices at a specific
+ location
+
+These filters allow for more granular control over which devices are
+upgraded, making it easier to perform staged rollouts or target specific
+subsets of your device fleet.
-Right now, the update of the upgrade information is not asynchronous yet,
-so you will have to reload the page periodically to find new information.
-This will be addressed in a future release.
+After reviewing the selection and setting any desired filters, you can
+either confirm the operation or cancel.
+
+Once the operation is confirmed you will be redirected to a page in which
+you can monitor the progress of the upgrade operations in real time.
diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst
index 963e63413..bfa10e1f4 100644
--- a/docs/user/rest-api.rst
+++ b/docs/user/rest-api.rst
@@ -83,7 +83,7 @@ List Mass Upgrade Operations
The list of batch upgrade operations provides the following filters:
- ``build`` (Firmware build ID)
-- ``status`` (One of: idle, in-progress, success, failed)
+- ``status`` (One of: idle, in-progress, success, failed, cancelled)
Here's a few examples:
@@ -210,6 +210,25 @@ Upgrades all the devices related to the specified build ID.
POST /api/v1/firmware-upgrader/build/{id}/upgrade/
+**Optional Parameters**
+
+The batch upgrade operation accepts the following optional parameters in
+the request body:
+
+- ``group`` (Device group ID): limit the upgrade to devices belonging to a
+ specific group
+- ``location`` (Location ID): limit the upgrade to devices at a specific
+ geographic location
+
+Example with filters:
+
+.. code-block:: json
+
+ {
+ "group": "{group_id}",
+ "location": "{location_id}"
+ }
+
Dry-run Batch Upgrade
~~~~~~~~~~~~~~~~~~~~~
@@ -223,6 +242,25 @@ exists for a device which would be upgraded.
GET /api/v1/firmware-upgrader/build/{id}/upgrade/
+**Optional Query Parameters**
+
+The dry-run batch upgrade operation accepts the following optional query
+parameters:
+
+- ``group`` (Device group ID): limit the preview to devices belonging to a
+ specific group
+- ``location`` (Location ID): limit the preview to devices at a specific
+ geographic location
+
+If both ``group`` and ``location`` are provided, only devices matching
+both filters are included.
+
+Example with filters:
+
+.. code-block:: text
+
+ GET /api/v1/firmware-upgrader/build/{id}/upgrade/?group={group_id}&location={location_id}
+
List Firmware Categories
~~~~~~~~~~~~~~~~~~~~~~~~
@@ -280,7 +318,7 @@ The list of upgrade operations provides the following filters:
- ``device__organization_slug`` (Organization slug of the device)
- ``device`` (Device ID)
- ``image`` (Firmware image ID)
-- ``status`` (One of: in-progress, success, failed, aborted)
+- ``status`` (One of: in-progress, success, failed, aborted, cancelled)
Here's a few examples:
@@ -299,6 +337,18 @@ Get Upgrade Operation Details
GET /api/v1/firmware-upgrader/upgrade-operation/{id}
+Cancel Upgrade Operation
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. code-block:: text
+
+ POST /api/v1/firmware-upgrader/upgrade-operation/{id}/cancel/
+
+.. note::
+
+ This endpoint may return a 409 status code if the operation cannot be
+ cancelled.
+
List Device Upgrade Operations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -309,7 +359,7 @@ List Device Upgrade Operations
**Available filters**
The list of device upgrade operations can be filtered by ``status`` (one
-of: in-progress, success, failed, aborted).
+of: in-progress, success, failed, aborted, cancelled).
.. code-block:: text
diff --git a/docs/user/upgrade-status.rst b/docs/user/upgrade-status.rst
new file mode 100644
index 000000000..630de19a2
--- /dev/null
+++ b/docs/user/upgrade-status.rst
@@ -0,0 +1,185 @@
+Upgrade Status
+==============
+
+OpenWISP Firmware Upgrader tracks the progress of firmware upgrade
+operations through different status values. Understanding these statuses
+is helpful for monitoring upgrade operations and troubleshooting issues.
+
+.. contents:: **Table of contents**:
+ :depth: 2
+ :local:
+
+Upgrade Operation Status Reference
+----------------------------------
+
+In Progress
+~~~~~~~~~~~
+
+**Status**: ``in-progress``
+
+**Description**: The upgrade operation is currently running. This includes
+all phases of the upgrade process: device connection, firmware validation,
+file upload, and firmware flashing.
+
+**What happens during this status:**
+
+- Device identity verification
+- Firmware image validation
+- Some non-critical services may be stopped to free up memory, if needed
+- Image upload to the device
+- Firmware flashing process
+
+**User Actions**: Users can cancel upgrade operations that are in
+progress, but only before the firmware flashing phase begins (typically
+when progress is below 65%).
+
+Success
+~~~~~~~
+
+**Status**: ``success``
+
+**Description**: The firmware upgrade completed successfully. The device
+has been upgraded to the new firmware version and is functioning properly.
+
+**What this means:**
+
+- The firmware was successfully flashed to the device
+- The device rebooted with the new firmware
+- Connectivity was restored after the upgrade
+- All verification checks passed
+
+**Next Steps**: No action required. The upgrade is complete and the device
+is running the new firmware.
+
+Failed
+~~~~~~
+
+**Status**: ``failed``
+
+**Description**: The upgrade operation completed, but the system could not
+reach the device again after the upgrade.
+
+**Common causes:**
+
+- Hardware failures
+- Unexpected system errors
+- The network became unreachable after flashing the new firmware
+
+**Recommended Actions**:
+
+- Check network connectivity
+- Physical inspection and/or serial console debugging
+
+Aborted
+~~~~~~~
+
+**Status**: ``aborted``
+
+**Description**: The upgrade operation was stopped due to prerequisites
+not being met. The system determined it was unsafe or impossible to
+proceed with the upgrade.
+
+**Common causes:**
+
+- Device UUID mismatch (wrong device targeted)
+- Insufficient memory on the device
+- Invalid or corrupted firmware image
+
+**What happens when aborted:**
+
+- The upgrade stops immediately
+- If services were stopped to free up memory, they are automatically
+ restarted
+- No firmware changes are made to the device
+- Device remains in its original state
+
+**Recommended Actions**:
+
+- Verify the correct device is selected
+- Check firmware image compatibility
+- Ensure device has sufficient memory
+
+Cancelled
+~~~~~~~~~
+
+**Status**: ``cancelled``
+
+**Description**: The upgrade operation was manually stopped by the user
+before completion. This is a deliberate action taken through the admin
+interface or REST API.
+
+Users can cancel upgrades through the admin interface using the "Cancel"
+button that appears next to in-progress operations.
+
+**When cancellation is possible:**
+
+- During the early stages of upgrade (typically before 65% progress)
+- Before the new firmware image is written to the flash memory of the
+ network device
+- While the operation status is still "in-progress"
+
+**What happens when the upgrade operation is cancelled:**
+
+- The upgrade process stops immediately
+- If services were stopped during the upgrade, they are automatically
+ restarted
+- No firmware changes are made to the device
+- Device remains in its original state
+
+Status Flow
+-----------
+
+A firmware upgrade operation always starts in the ``in-progress`` state.
+From there, it can transition into one of several terminal states
+depending on how the operation concludes.
+
+Successful Flow
+~~~~~~~~~~~~~~~
+
+In the normal case, the upgrade proceeds without interruption:
+
+1. ``in-progress``: the upgrade is executed;
+2. ``success``: the device reboots and becomes reachable again.
+
+This indicates a fully completed and verified upgrade.
+
+Interrupted or Unsuccessful Flows
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+An upgrade may also end prematurely or unsuccessfully:
+
+- ``aborted``: the system detects that one or more safety preconditions
+ are not met *before* flashing begins and stops the operation without
+ making any changes to the device;
+- ``cancelled``: the user manually stops the upgrade while it is still
+ safe to do so, firmware flash is prevented;
+- ``failed``: the firmware flashing process completes, but the device does
+ not become reachable afterward, it usually indicates a post-flash
+ failure.
+
+Terminal States
+~~~~~~~~~~~~~~~
+
+The following statuses are terminal and will not transition further:
+
+- ``success``
+- ``failed``
+- ``aborted``
+- ``cancelled``
+
+Once a terminal state is reached, a new upgrade operation must be
+initiated to retry or recover.
+
+Monitoring Upgrades
+-------------------
+
+**Real-time Progress**: The admin interface provides real-time updates of
+upgrade operations, including progress percentages and detailed logs. See
+:doc:`websocket-api` for details on the WebSocket API used to deliver
+these updates.
+
+**Upgrade Logs**: Each status change is logged with detailed information
+about what occurred during the upgrade process.
+
+**Batch Operations**: When performing mass upgrades, you can monitor the
+status of individual device upgrades within the batch operation.
diff --git a/docs/user/websocket-api.rst b/docs/user/websocket-api.rst
new file mode 100644
index 000000000..743357956
--- /dev/null
+++ b/docs/user/websocket-api.rst
@@ -0,0 +1,246 @@
+WebSocket Reference
+===================
+
+.. contents:: **Table of contents**:
+ :depth: 2
+ :local:
+
+Overview
+--------
+
+The WebSocket API provides real-time updates for firmware upgrade
+operations.
+
+All endpoints:
+
+- Use JSON messages.
+- Support requesting the current state of the connection scope.
+- May push real-time updates after the connection is established.
+
+Authentication and Authorization
+--------------------------------
+
+All WebSocket endpoints require an authenticated user.
+
+A connection is accepted only if the user is authorized to access the
+requested resource. The connection is closed immediately if authorization
+fails.
+
+A user is authorized if:
+
+- The user is a superuser, OR
+- The user:
+
+ - Is marked as staff,
+ - Has either ``view`` or ``change`` permission on the relevant object,
+ - Is an organization admin (manager) for the object's organization.
+
+Connection Endpoints
+--------------------
+
+1. Upgrade Operation
+~~~~~~~~~~~~~~~~~~~~
+
+Connection URL:
+
+::
+
+ wss:///ws/firmware-upgrader/upgrade-operation//
+
+Scope
++++++
+
+A single upgrade operation.
+
+Client Message
+++++++++++++++
+
+To request the current state of the operation:
+
+.. code-block:: javascript
+
+ {
+ "type": "request_current_state", // Required. Requests current operation state.
+ "operation_id": "" // Must match the in the URL.
+ }
+
+.. warning::
+
+ Any other message type is ignored.
+
+When the client sends ``request_current_state``, the server responds with
+exactly one message:
+
+.. code-block:: javascript
+
+ {
+ "type": "operation_update", // Message type identifier
+ "operation": {
+ "id": "", // Operation identifier
+ "device": "", // Device identifier
+ "image": "", // Firmware image identifier
+ "status": "", // Current operation status
+ "log": "", // Operation log output
+ "progress": , // Progress percentage (0–100)
+ "modified": "", // Last modification timestamp (ISO 8601)
+ "created": "" // Creation timestamp (ISO 8601)
+ }
+ }
+
+Real-time Updates
++++++++++++++++++
+
+After the connection is established, the server pushes
+``operation_update`` messages whenever the operation state changes.
+
+The message structure is identical to the response returned for
+``request_current_state``.
+
+2. Batch Upgrade Operation
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Connection URL:
+
+::
+
+ wss:///ws/firmware-upgrader/batch-upgrade-operation//
+
+Scope
++++++
+
+A batch upgrade containing multiple operations.
+
+Client Message
+++++++++++++++
+
+To request the current state of the batch:
+
+.. code-block:: javascript
+
+ {
+ "type": "request_current_state", // Required. Requests current batch state.
+ "batch_id": "" // Must match the in the URL.
+ }
+
+.. warning::
+
+ Any other message type is ignored.
+
+When the client sends ``request_current_state``, the server responds with
+exactly one message:
+
+.. code-block:: javascript
+
+ {
+ "type": "batch_state", // Message type identifier
+ "batch_status": {
+ "status": "", // Overall batch status
+ "completed": , // Number of completed operations
+ "total": // Total operations in the batch
+ },
+ "operations": [
+ {
+ "id": "", // Operation identifier
+ "device": "", // Device identifier
+ "image": "", // Firmware image identifier
+ "status": "", // Operation status
+ "log": "", // Operation log output
+ "progress": , // Progress percentage (0–100)
+ "modified": "", // Last modification timestamp
+ "created": "" // Creation timestamp
+ }
+ ]
+ }
+
+Real-time Updates
++++++++++++++++++
+
+The endpoint may push:
+
+``operation_progress``
+
+.. code-block:: javascript
+
+ {
+ "type": "operation_progress", // Message type identifier
+ "operation_id": "", // Operation identifier
+ "status": "", // Operation status
+ "progress": , // Progress percentage (0–100)
+ "modified": "", // Last modification timestamp
+ "device_id": "", // Device identifier
+ "device_name": "", // Device display name
+ "image_name": "" // Firmware image display name
+ }
+
+``batch_status``
+
+.. code-block:: javascript
+
+ {
+ "type": "batch_status", // Message type identifier
+ "status": "", // Overall batch status
+ "completed": , // Number of completed operations
+ "total": // Total operations in the batch
+ }
+
+3. Device Upgrade
+~~~~~~~~~~~~~~~~~
+
+Connection URL:
+
+::
+
+ wss:///ws/firmware-upgrader/device//
+
+Scope
++++++
+
+Recent and ongoing upgrade operations for a device.
+
+Client Message
+++++++++++++++
+
+To request the current state for the device:
+
+.. code-block:: javascript
+
+ {
+ "type": "request_current_state", // Required. Requests device upgrade state.
+ "device_id": "" // Must match the in the URL.
+ }
+
+.. warning::
+
+ Any other message type is ignored.
+
+When ``request_current_state`` is sent, the server sends up to five
+separate messages (one per operation).
+
+Each message uses the following envelope:
+
+.. code-block:: javascript
+
+ {
+ "model": "UpgradeOperation", // Model identifier
+ "data": {
+ "type": "operation_update", // Message type identifier
+ "operation": {
+ "id": "", // Operation identifier
+ "device": "", // Device identifier
+ "image": "", // Firmware image identifier
+ "status": "", // Operation status
+ "log": "", // Operation log output
+ "progress": , // Progress percentage (0–100)
+ "modified": "" // Last modification timestamp
+ }
+ }
+ }
+
+Real-time Updates
++++++++++++++++++
+
+After the connection is established, the server forwards
+``operation_update`` events for upgrade operations related to the device.
+
+Real-time messages use the same envelope structure as described above and
+are emitted individually as operation state changes occur.
diff --git a/openwisp_firmware_upgrader/admin.py b/openwisp_firmware_upgrader/admin.py
index 3a99b9f92..6e07f1e63 100644
--- a/openwisp_firmware_upgrader/admin.py
+++ b/openwisp_firmware_upgrader/admin.py
@@ -9,6 +9,7 @@
from django.contrib import admin, messages
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.core.exceptions import ValidationError
+from django.core.paginator import InvalidPage, Paginator
from django.core.serializers.json import DjangoJSONEncoder
from django.shortcuts import redirect
from django.template.response import TemplateResponse
@@ -28,12 +29,15 @@
from .filters import (
BuildCategoryFilter,
BuildCategoryOrganizationFilter,
+ BuildFilter,
CategoryFilter,
CategoryOrganizationFilter,
+ GroupFilter,
+ LocationFilter,
)
from .swapper import load_model
from .utils import get_upgrader_schema_for_device
-from .widgets import FirmwareSchemaWidget
+from .widgets import FirmwareSchemaWidget, MassUpgradeSelect2Widget
logger = logging.getLogger(__name__)
BatchUpgradeOperation = load_model("BatchUpgradeOperation")
@@ -44,6 +48,9 @@
Build = load_model("Build")
Device = swapper.load_model("config", "Device")
DeviceConnection = swapper.load_model("connection", "DeviceConnection")
+Organization = swapper.load_model("openwisp_users", "Organization")
+Location = swapper.load_model("geo", "Location")
+DeviceGroup = swapper.load_model("config", "DeviceGroup")
class BaseAdmin(MultitenantAdminMixin, TimeReadonlyAdminMixin, admin.ModelAdmin):
@@ -98,18 +105,61 @@ class BatchUpgradeConfirmationForm(forms.ModelForm):
build = forms.ModelChoiceField(
widget=forms.HiddenInput(), required=False, queryset=Build.objects.all()
)
+ group = forms.ModelChoiceField(
+ queryset=DeviceGroup.objects.none(),
+ required=False,
+ help_text=_("Limit the upgrade to devices belonging to this group"),
+ widget=MassUpgradeSelect2Widget(placeholder=_("Select a group")),
+ )
+ location = forms.ModelChoiceField(
+ queryset=Location.objects.none(),
+ required=False,
+ help_text=_("Limit the upgrade to devices at this location"),
+ widget=MassUpgradeSelect2Widget(placeholder=_("Select a location")),
+ )
class Meta:
model = BatchUpgradeOperation
- fields = ("build", "upgrade_options")
+ fields = ("build", "group", "location", "upgrade_options")
+
+ def __init__(self, *args, **kwargs):
+ self.user = kwargs.pop("user")
+ super().__init__(*args, **kwargs)
+ build = self.initial.get("build")
+ device_group_qs = DeviceGroup.objects
+ location_qs = Location.objects
+ organization_id = None
+ if build:
+ organization_id = build.category.organization_id
+ if organization_id:
+ device_group_qs = device_group_qs.filter(organization_id=organization_id)
+ location_qs = location_qs.filter(organization_id=organization_id)
+ if not self.user.is_superuser:
+ device_group_qs = device_group_qs.filter(
+ organization_id__in=self.user.organizations_managed
+ )
+ location_qs = location_qs.filter(
+ organization_id__in=self.user.organizations_managed
+ )
+ self.fields["group"].queryset = device_group_qs
+ self.fields["location"].queryset = location_qs
- @property
- def media(self):
+ class Media:
+ # We don't need to include any select2 JS/CSS files as they are
+ # included by the JSONSchemaWidget used for upgrade_options.
js = [
+ "admin/js/jquery.init.js",
"firmware-upgrader/js/upgrade-selected-confirmation.js",
+ "firmware-upgrader/js/mass-upgrade-select2.js",
]
- css = {"all": ["firmware-upgrader/css/upgrade-selected-confirmation.css"]}
- return super().media + forms.Media(js=js, css=css)
+ css = {
+ "all": [
+ "admin/css/forms.css",
+ "admin/css/autocomplete.css",
+ "admin/css/ow-auto-filter.css",
+ "firmware-upgrader/css/upgrade-selected-confirmation.css",
+ ]
+ }
@admin.register(load_model("Build"))
@@ -156,31 +206,59 @@ def upgrade_selected(self, request, queryset):
upgrade_all = request.POST.get("upgrade_all")
upgrade_related = request.POST.get("upgrade_related")
upgrade_options = request.POST.get("upgrade_options")
- form = BatchUpgradeConfirmationForm()
+ group_id = request.POST.get("group")
+ location_id = request.POST.get("location")
build = queryset.first()
+ form = BatchUpgradeConfirmationForm(initial={"build": build}, user=request.user)
# upgrade has been confirmed
if upgrade_all or upgrade_related:
form = BatchUpgradeConfirmationForm(
- data={"upgrade_options": upgrade_options, "build": build}
+ data={
+ "upgrade_options": upgrade_options,
+ "build": build,
+ "group": group_id,
+ "location": location_id,
+ },
+ user=request.user,
)
form.full_clean()
if not form.errors:
upgrade_options = form.cleaned_data["upgrade_options"]
- batch = build.batch_upgrade(
- firmwareless=upgrade_all, upgrade_options=upgrade_options
- )
- text = _(
- "You can track the progress of this mass upgrade operation "
- "in this page. Refresh the page from time to time to check "
- "its progress."
- )
- self.message_user(request, mark_safe(text), messages.SUCCESS)
- url = reverse(
- f"admin:{app_label}_batchupgradeoperation_change", args=[batch.pk]
- )
- return redirect(url)
- # upgrade needs to be confirmed
- result = BatchUpgradeOperation.dry_run(build=build)
+ group = form.cleaned_data.get("group")
+ location = form.cleaned_data.get("location")
+ try:
+ batch = build.batch_upgrade(
+ firmwareless=upgrade_all,
+ upgrade_options=upgrade_options,
+ group=group,
+ location=location,
+ )
+ # Success message for when batch upgrade starts successfully
+ text = _(
+ "You can track the progress of this mass upgrade operation "
+ "in this page."
+ )
+ self.message_user(request, mark_safe(text), messages.SUCCESS)
+ url = reverse(
+ f"admin:{app_label}_batchupgradeoperation_change",
+ args=[batch.pk],
+ )
+ return redirect(url)
+ except ValidationError as e:
+ self.message_user(
+ request, str(e.messages[0] if e.messages else e), messages.ERROR
+ )
+ dry_run_kwargs = {
+ "build": build,
+ }
+ if form.is_bound:
+ group = form.cleaned_data.get("group") if not form.errors else None
+ location = form.cleaned_data.get("location") if not form.errors else None
+ dry_run_kwargs["group"] = group
+ dry_run_kwargs["location"] = location
+ result = BatchUpgradeOperation.dry_run(
+ **dry_run_kwargs,
+ )
related_device_fw = result["device_firmwares"]
firmwareless_devices = result["devices"]
title = _("Confirm mass upgrade operation")
@@ -189,7 +267,6 @@ def upgrade_selected(self, request, queryset):
related_device_fw=related_device_fw,
firmwareless_devices=firmwareless_devices,
)
-
context.update(
{
"title": title,
@@ -201,10 +278,14 @@ def upgrade_selected(self, request, queryset):
"firmware_upgrader_schema": json.dumps(
upgrader_schema, cls=DjangoJSONEncoder
),
+ "upgrade_operation_path": reverse(
+ f"admin:{app_label}_upgradeoperation_change",
+ args=["00000000-0000-0000-0000-000000000000"],
+ ),
"build": build,
"opts": opts,
"action_checkbox_name": ACTION_CHECKBOX_NAME,
- "media": self.media,
+ "media": self.media + form.media,
}
)
request.current_app = self.admin_site.name
@@ -219,21 +300,20 @@ def upgrade_selected(self, request, queryset):
context,
)
- upgrade_selected.short_description = (
- "Mass-upgrade devices related " "to the selected build"
- )
-
- def change_view(self, request, object_id, form_url="", extra_context=None):
+ def change_view(self, request, object_id, extra_context=None, **kwargs):
app_label = self.model._meta.app_label
extra_context = extra_context or {}
upgrade_url = f"{app_label}_build_changelist"
extra_context.update({"upgrade_url": upgrade_url})
- return super().change_view(request, object_id, form_url, extra_context)
+ extra_context["django_locale"] = get_language()
+ return super().change_view(
+ request, object_id, extra_context=extra_context, **kwargs
+ )
class UpgradeOperationForm(forms.ModelForm):
class Meta:
- fields = ["device", "image", "status", "log", "modified"]
+ fields = ["image", "status", "log", "modified"]
labels = {"modified": _("last updated")}
@@ -254,6 +334,10 @@ class Media:
class ReadonlyUpgradeOptionsMixin:
+
+ class Media:
+ css = {"all": ["firmware-upgrader/css/upgrade-options.css"]}
+
@admin.display(description=_("Upgrade options"))
def readonly_upgrade_options(self, obj):
upgrader_schema = obj.upgrader_schema
@@ -265,12 +349,78 @@ def readonly_upgrade_options(self, obj):
option_title = value["title"]
icon_url = static(f"admin/img/icon-{option_used}.svg")
options.append(
- f'
{% endblock %}
{% block content %}
{% if related_count or firmwareless_count %}
-
{% blocktrans %}Are you sure you want to proceed with this batch upgrade operation?{% endblocktrans %}
+
+ {% blocktrans %}
+ Are you sure you want to proceed with the
+ mass upgrade operation for {{ build }}?
+ {% endblocktrans %}
+
{% elif not related_count and not firmwareless_count %}
-
{% blocktrans %}No devices to upgrade were found.
- Hint: Maybe they have been all already updated previously.{% endblocktrans %}
- {% endif %}
- {% if related_count %}
-
{% trans "Devices related to build" %} {{ build }} ({{ related_count }})
-
- {% for device_fw in related_device_fw %}
-
{{ device_fw.device.name }}
- {% endfor %}
-
- {% endif %}
- {% if firmwareless_count %}
-
{% trans "Devices which the system has never upgraded yet" %} ({{ firmwareless_count }})
-
- {% for device in firmwareless_devices %}
-
{{ device.name }}
- {% endfor %}
-
+
+ {% blocktrans %}
+ No devices to upgrade were found.
+ Hint: Maybe they have been all already updated previously.
+ {% endblocktrans %}
+
{% endif %}
-
{% endblock %}
-
{% block footer %}
-{{ block.super }}
-
-{{ form.media }}
+ {{ block.super }}
+
{% endblock footer %}
diff --git a/openwisp_firmware_upgrader/tests/base.py b/openwisp_firmware_upgrader/tests/base.py
index e8d59f68c..236658785 100644
--- a/openwisp_firmware_upgrader/tests/base.py
+++ b/openwisp_firmware_upgrader/tests/base.py
@@ -9,6 +9,7 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from openwisp_controller.connection.tests.utils import CreateConnectionsMixin
+from openwisp_utils.tests.selenium import SeleniumTestMixin
from ..swapper import load_model
@@ -16,8 +17,8 @@
Category = load_model("Category")
FirmwareImage = load_model("FirmwareImage")
DeviceFirmware = load_model("DeviceFirmware")
-DeviceFirmware = load_model("DeviceFirmware")
Credentials = swapper.load_model("connection", "Credentials")
+DeviceGroup = swapper.load_model("config", "DeviceGroup")
OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser")
@@ -153,6 +154,7 @@ def _create_upgrade_env(
build=build2, type=self.TPLINK_4300_IL_IMAGE
)
data = {
+ "category": category,
"build1": build1,
"build2": build2,
"d1": d1,
@@ -202,6 +204,30 @@ def _create_device_with_connection(self, **kwargs):
self._create_device_connection(device=d1)
return d1
+ def _create_device_group(self, **kwargs):
+ """Create a device group for testing."""
+ opts = dict(name="Test Group")
+ opts.update(kwargs)
+ if "organization" not in opts:
+ opts["organization"] = self._get_org()
+ group = DeviceGroup(**opts)
+ group.full_clean()
+ group.save()
+ return group
+
+
+class SeleniumTestMixin(SeleniumTestMixin):
+ def _assert_no_js_errors(self):
+ browser_logs = []
+ for log in self.get_browser_logs():
+ # capture SEVERE-level entries from both JS runtime and console API
+ if log.get("level") == "SEVERE" and log.get("source") in (
+ "javascript",
+ "console-api",
+ ):
+ browser_logs.append(log)
+ self.assertEqual(browser_logs, [])
+
def spy_mock(method, pre_action):
magicmock = mock.MagicMock()
diff --git a/openwisp_firmware_upgrader/tests/test_admin.py b/openwisp_firmware_upgrader/tests/test_admin.py
index 06e8bd5a7..6e39a487c 100644
--- a/openwisp_firmware_upgrader/tests/test_admin.py
+++ b/openwisp_firmware_upgrader/tests/test_admin.py
@@ -14,6 +14,7 @@
from openwisp_controller.config.tests.test_admin import TestAdmin as TestConfigAdmin
from openwisp_controller.connection import settings as conn_settings
from openwisp_firmware_upgrader.admin import (
+ BatchUpgradeConfirmationForm,
BuildAdmin,
DeviceAdmin,
DeviceFirmwareForm,
@@ -39,6 +40,9 @@
UpgradeOperation = load_model("UpgradeOperation")
BatchUpgradeOperation = load_model("BatchUpgradeOperation")
Device = swapper.load_model("config", "Device")
+Location = swapper.load_model("geo", "Location")
+DeviceLocation = swapper.load_model("geo", "DeviceLocation")
+DeviceConnection = swapper.load_model("connection", "DeviceConnection")
class MockRequest:
@@ -151,7 +155,7 @@ def test_upgrade_selected_error(self):
def test_upgrade_intermediate_page_related(self):
self._login()
env = self._create_upgrade_env()
- with self.assertNumQueries(13):
+ with self.assertNumQueries(15):
r = self.client.post(
self.build_list_url,
{
@@ -160,14 +164,12 @@ def test_upgrade_intermediate_page_related(self):
},
follow=True,
)
- self.assertContains(r, "Devices related to build")
- self.assertNotContains(r, "has never upgraded yet")
self.assertNotContains(r, '{org.name}'
+
+ org1_option = _get_org_option(org1)
+ org2_option = _get_org_option(org2)
+ with self.subTest(
+ "Superuser: Organization filter is visible for shared category"
+ ):
+ self._login()
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "By organization")
+ self.assertContains(
+ response,
+ org1_option,
+ html=True,
+ )
+ self.assertContains(
+ response,
+ org2_option,
+ html=True,
+ )
+
+ with self.subTest("Org admin: Cannot view shared mass upgrade operation"):
+ self.client.force_login(org1_admin)
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.request["PATH_INFO"], reverse("admin:index"))
+ self.assertContains(
+ response,
+ f'
Mass upgrade operation with ID “{batch.pk}”'
+ " doesn’t exist. Perhaps it was deleted?
",
+ html=True,
+ )
+
+ env["category"].organization = org1
+ env["category"].save()
+ with self.subTest(
+ "Superuser: Organization filter hidden when category belongs to org"
+ ):
+ self._login()
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "By organization")
+
+ with self.subTest(
+ "Org admin: Organization filter hidden when category belongs to org"
+ ):
+ self.client.force_login(org1_admin)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "By organization")
+
+ def test_batch_upgrade_operation_filters(self, *args):
+ """Test that filter UI elements are displayed correctly for organization admin"""
+ env = self._create_upgrade_env()
+ org_admin = self._create_administrator(organizations=[env["d1"].organization])
+ self.client.force_login(org_admin)
+ batch = env["build2"].batch_upgrade(firmwareless=True)
+ url = reverse(
+ f"admin:{self.app_label}_batchupgradeoperation_change", args=[batch.pk]
+ )
+
+ with self.subTest("Test filter UI elements are present"):
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ # Check status filter options
+ self.assertContains(response, "By status")
+ self.assertContains(response, 'title="in progress"')
+ self.assertContains(response, 'title="success"')
+ self.assertContains(response, 'title="failed"')
+ # Organization filter should not be present because the build's category is not shared
+ self.assertNotContains(response, "By organization")
+
+ with self.subTest("Test active filter indication"):
+ # Test with status filter active
+ response = self.client.get(url + "?status=in-progress")
+ self.assertEqual(response.status_code, 200)
+ # Check that the in-progress status is selected
+ self.assertContains(
+ response,
+ (
+ ''
+ "in progress"
+ ),
+ html=True,
+ )
+
+ with self.subTest("Filter link building preserves other GET params"):
+ # Apply search and status simultaneously and verify the generated
+ # "All" choice keeps the search parameter when building its
+ # query string. The href for the idle option will still contain
+ # both params, but the important part is the all link.
+ response = self.client.get(url + "?q=testsearch&status=idle")
+ self.assertEqual(response.status_code, 200)
+ content = response.content.decode()
+ self.assertIn('href="?q=testsearch"', content)
+ self.assertContains(
+ response,
+ 'All',
+ html=True,
+ )
+
_mock_upgrade = "openwisp_firmware_upgrader.upgraders.openwrt.OpenWrt.upgrade"
_mock_connect = "openwisp_controller.connection.models.DeviceConnection.connect"
@@ -559,8 +676,7 @@ def test_upgrade_selected_action_perms(self, *args):
obj=env["build1"],
message=(
"You can track the progress of this mass upgrade operation "
- "in this page. Refresh the page from time to time to check "
- "its progress."
+ "in this page."
),
required_perms=["change"],
extra_payload={
@@ -887,7 +1003,7 @@ def test_using_upgrade_options_with_unsupported_upgrader(self, *args):
reverse("admin:config_device_change", args=[device.id])
)
self.assertContains(
- response, ""
+ response, "