From 77e841389c4ee734151960e486ad84f7687aec0f Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 24 Apr 2026 00:56:16 +0530 Subject: [PATCH 01/12] [test(geo)] Add regression tests for deactivated device WHOIS handling #1325 This commit adds regression tests to ensure that the WHOIS handling for deactivated devices works as expected. --- null | 1 + openwisp_controller/config/whois/service.py | 4 +++ .../config/whois/tests/tests.py | 35 +++++++++++++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 null diff --git a/null b/null new file mode 100644 index 000000000..992b1eeb8 --- /dev/null +++ b/null @@ -0,0 +1 @@ +fatal: no rebase in progress diff --git a/openwisp_controller/config/whois/service.py b/openwisp_controller/config/whois/service.py index 43a6ab3db..304a0d1fe 100644 --- a/openwisp_controller/config/whois/service.py +++ b/openwisp_controller/config/whois/service.py @@ -210,6 +210,8 @@ def process_ip_data_and_location(self, force_lookup=False): Trigger WHOIS lookup based on the conditions of `_need_whois_lookup`. Tasks are triggered on commit to ensure redundant data is not created. """ + if self.device.is_deactived(): + return new_ip = self.device.last_ip initial_ip = self.device._initial_last_ip if force_lookup or self._need_whois_lookup(new_ip): @@ -229,6 +231,8 @@ def update_whois_info(self): when the data is older than ``OPENWISP_CONTROLLER_WHOIS_REFRESH_THRESHOLD_DAYS``. """ + if self.device.is_deactived(): + return ip_address = self.device.last_ip if not self.is_valid_public_ip_address(ip_address): return diff --git a/openwisp_controller/config/whois/tests/tests.py b/openwisp_controller/config/whois/tests/tests.py index 05aaf644e..38241f09f 100644 --- a/openwisp_controller/config/whois/tests/tests.py +++ b/openwisp_controller/config/whois/tests/tests.py @@ -2,7 +2,7 @@ import importlib from datetime import timedelta from io import StringIO -from unittest import mock +from unittest import mock, mocke from uuid import uuid4 from django.conf import settings @@ -21,7 +21,9 @@ from selenium.webdriver.common.by import By from swapper import load_model +from openwisp_controller.config import settings as app_settings from openwisp_controller.config.signals import whois_fetched, whois_lookup_skipped +from openwisp_controller.config.whois.service import WHOISService from openwisp_utils.tests import SeleniumTestMixin, catch_signal from ....tests.utils import TestAdminMixin @@ -346,8 +348,8 @@ def test_last_ip_management_command_queries(self): name="default.test.device4", mac_address="66:33:44:55:66:77" ) args = ["--noinput"] - # 4 queries (3 for each device's last_ip update) and 1 for fetching devices - with self.assertNumQueries(4): + + with self.assertNumQueries(7): call_command("clear_last_ip", *args, stdout=out, stderr=StringIO()) @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) @@ -1186,3 +1188,30 @@ def _assert_no_js_errors(): _assert_no_js_errors() except UnexpectedAlertPresentException: self.fail("XSS vulnerability detected in WHOIS details admin view.") + + +class TestWHOISDeactivated(TransactionTestCase): + def setUp(self): + self.device = self._create_device() + self.device.last_ip = "8.8.8.8" # public IP + self.device.save() + + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) + @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") + def test_process_ip_skips_when_deactivated(self, mock_task): + self.device._is_deactivated = True + + service = WHOISService(self.device) + service.process_ip_data_and_location() + + self.assertEqual(mock_task.call_count, 0) + + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) + @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") + def test_process_ip_runs_when_active(self, mock_task): + self.device._is_deactivated = False + + service = WHOISService(self.device) + service.process_ip_data_and_location() + + self.assertEqual(mock_task.call_count, 1) From a7b9842eb0d64007f9d86fcfbc05ffdfe3b2c3d3 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 24 Apr 2026 01:17:08 +0530 Subject: [PATCH 02/12] [test(geo)] Add regression tests for deactivated device WHOIS handling #1325 This commit adds regression tests to ensure that the WHOIS handling for deactivated devices works as expected. --- openwisp_controller/config/whois/service.py | 4 ++-- openwisp_controller/config/whois/tests/tests.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openwisp_controller/config/whois/service.py b/openwisp_controller/config/whois/service.py index 304a0d1fe..a98d44dd6 100644 --- a/openwisp_controller/config/whois/service.py +++ b/openwisp_controller/config/whois/service.py @@ -210,7 +210,7 @@ def process_ip_data_and_location(self, force_lookup=False): Trigger WHOIS lookup based on the conditions of `_need_whois_lookup`. Tasks are triggered on commit to ensure redundant data is not created. """ - if self.device.is_deactived(): + if self.device.is_deactivated(): return new_ip = self.device.last_ip initial_ip = self.device._initial_last_ip @@ -231,7 +231,7 @@ def update_whois_info(self): when the data is older than ``OPENWISP_CONTROLLER_WHOIS_REFRESH_THRESHOLD_DAYS``. """ - if self.device.is_deactived(): + if self.device.is_deactivated(): return ip_address = self.device.last_ip if not self.is_valid_public_ip_address(ip_address): diff --git a/openwisp_controller/config/whois/tests/tests.py b/openwisp_controller/config/whois/tests/tests.py index 38241f09f..bc1f064c9 100644 --- a/openwisp_controller/config/whois/tests/tests.py +++ b/openwisp_controller/config/whois/tests/tests.py @@ -2,7 +2,7 @@ import importlib from datetime import timedelta from io import StringIO -from unittest import mock, mocke +from unittest import mock from uuid import uuid4 from django.conf import settings From 8d528e7e86bb4e9973f9d5c94e08ff6e6126f50c Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 24 Apr 2026 15:52:32 +0530 Subject: [PATCH 03/12] Update tests.py --- openwisp_controller/config/whois/tests/tests.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openwisp_controller/config/whois/tests/tests.py b/openwisp_controller/config/whois/tests/tests.py index bc1f064c9..0ab5d1f78 100644 --- a/openwisp_controller/config/whois/tests/tests.py +++ b/openwisp_controller/config/whois/tests/tests.py @@ -1190,11 +1190,15 @@ def _assert_no_js_errors(): self.fail("XSS vulnerability detected in WHOIS details admin view.") -class TestWHOISDeactivated(TransactionTestCase): +class TestWHOISDeactivated( + CreateWHOISMixin, WHOISTransactionMixin, TransactionTestCase +): def setUp(self): + super().setUp() self.device = self._create_device() - self.device.last_ip = "8.8.8.8" # public IP - self.device.save() + org_settings = self.device.organization.config_settings + org_settings.whois_enabled = True + org_settings.save() @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") @@ -1210,6 +1214,7 @@ def test_process_ip_skips_when_deactivated(self, mock_task): @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") def test_process_ip_runs_when_active(self, mock_task): self.device._is_deactivated = False + self.device.last_ip = "8.8.8.8" service = WHOISService(self.device) service.process_ip_data_and_location() From 6ace4369a147f982e9ace9f7934e998830c1f0f8 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 24 Apr 2026 16:29:18 +0530 Subject: [PATCH 04/12] [fix] Skip WHOIS and estimated location for deactivated devices #1325 - add guard in WHOISService to skip processing for deactivated devices - add guard in EstimatedLocationService to prevent task scheduling - fix N+1 query by including _is_deactivated in clear_last_ip_command queryset - add regression tests ensuring tasks are skipped when device is deactivated - ensure deterministic tests by enabling whois on organization settings Fixes #1325 --- null | 1 - openwisp_controller/config/whois/commands.py | 2 +- .../config/whois/tests/tests.py | 97 ++++++++++++------- .../connection/tests/test_tasks.py | 5 +- .../geo/estimated_location/service.py | 2 + 5 files changed, 68 insertions(+), 39 deletions(-) delete mode 100644 null diff --git a/null b/null deleted file mode 100644 index 992b1eeb8..000000000 --- a/null +++ /dev/null @@ -1 +0,0 @@ -fatal: no rebase in progress diff --git a/openwisp_controller/config/whois/commands.py b/openwisp_controller/config/whois/commands.py index dafbe92e8..185ef9085 100644 --- a/openwisp_controller/config/whois/commands.py +++ b/openwisp_controller/config/whois/commands.py @@ -39,7 +39,7 @@ def clear_last_ip_command(stdout, stderr, interactive=True): ) # include the FK field 'organization' in .only() so the related # `organization__config_settings` traversal is not deferred - .only("last_ip", "organization", "key") + .only("last_ip", "organization", "key", "_is_deactivated") ) # Filter out devices that have WHOIS information for their last IP devices = devices.exclude(last_ip=None).exclude( diff --git a/openwisp_controller/config/whois/tests/tests.py b/openwisp_controller/config/whois/tests/tests.py index 0ab5d1f78..f5529ebb2 100644 --- a/openwisp_controller/config/whois/tests/tests.py +++ b/openwisp_controller/config/whois/tests/tests.py @@ -21,9 +21,7 @@ from selenium.webdriver.common.by import By from swapper import load_model -from openwisp_controller.config import settings as app_settings from openwisp_controller.config.signals import whois_fetched, whois_lookup_skipped -from openwisp_controller.config.whois.service import WHOISService from openwisp_utils.tests import SeleniumTestMixin, catch_signal from ....tests.utils import TestAdminMixin @@ -348,8 +346,8 @@ def test_last_ip_management_command_queries(self): name="default.test.device4", mac_address="66:33:44:55:66:77" ) args = ["--noinput"] - - with self.assertNumQueries(7): + # 4 queries (3 for each device's last_ip update) and 1 for fetching devices + with self.assertNumQueries(4): call_command("clear_last_ip", *args, stdout=out, stderr=StringIO()) @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) @@ -1079,6 +1077,65 @@ def test_get_whois_info_when_not_configured(self): result = get_whois_info(pk=device.pk) self.assertIsNone(result) + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) + @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") + def test_process_ip_skips_when_deactivated(self, mock_task): + # Public IP that would normally trigger a lookup, so the only reason + # the task is not enqueued is the deactivation guard under test. + device = self._create_device() + org_settings = device.organization.config_settings + org_settings.whois_enabled = True + org_settings.save() + device._initial_last_ip = None + device.last_ip = "8.8.8.8" + device.save() + mock_task.reset_mock() + device.deactivate() + service = WHOISService(device) + service.process_ip_data_and_location() + self.assertEqual(mock_task.call_count, 0) + + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) + @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") + def test_process_ip_runs_when_active(self, mock_task): + device = self._create_device() + org_settings = device.organization.config_settings + org_settings.whois_enabled = True + org_settings.save() + device._initial_last_ip = None + device.last_ip = "8.8.8.8" + device.save() + mock_task.reset_mock() + service = WHOISService(device) + service.process_ip_data_and_location() + # transaction.on_commit executes immediately in TransactionTestCase, + # so the task is triggered synchronously here + mock_task.assert_called_once_with( + device_pk=device.pk, + initial_ip_address=device._initial_last_ip, + ) + + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) + @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") + def test_update_whois_skips_when_deactivated(self, mock_task): + device = self._create_device() + org_settings = device.organization.config_settings + org_settings.whois_enabled = True + org_settings.save() + ip = "8.8.8.8" + device._initial_last_ip = None + device.last_ip = ip + device.save() + threshold = app_settings.WHOIS_REFRESH_THRESHOLD_DAYS + 1 + expired_time = timezone.now() - timedelta(days=threshold) + whois_obj = self._create_whois_info(ip_address=ip) + WHOISInfo.objects.filter(pk=whois_obj.pk).update(modified=expired_time) + device.deactivate() + mock_task.reset_mock() + service = WHOISService(device) + service.update_whois_info() + mock_task.assert_not_called() + @tag("selenium_tests") class TestWHOISSelenium(CreateWHOISMixin, SeleniumTestMixin, StaticLiveServerTestCase): @@ -1188,35 +1245,3 @@ def _assert_no_js_errors(): _assert_no_js_errors() except UnexpectedAlertPresentException: self.fail("XSS vulnerability detected in WHOIS details admin view.") - - -class TestWHOISDeactivated( - CreateWHOISMixin, WHOISTransactionMixin, TransactionTestCase -): - def setUp(self): - super().setUp() - self.device = self._create_device() - org_settings = self.device.organization.config_settings - org_settings.whois_enabled = True - org_settings.save() - - @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) - @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") - def test_process_ip_skips_when_deactivated(self, mock_task): - self.device._is_deactivated = True - - service = WHOISService(self.device) - service.process_ip_data_and_location() - - self.assertEqual(mock_task.call_count, 0) - - @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) - @mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay") - def test_process_ip_runs_when_active(self, mock_task): - self.device._is_deactivated = False - self.device.last_ip = "8.8.8.8" - - service = WHOISService(self.device) - service.process_ip_data_and_location() - - self.assertEqual(mock_task.call_count, 1) diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py index 8ce18a694..440272a9c 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -142,8 +142,11 @@ def test_launch_command_exception(self, *args): class TestTransactionTasks( TestRegistrationMixin, CreateConnectionsMixin, TransactionTestCase ): + @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") @mock.patch.object(tasks.update_config, "delay") - def test_update_config_hostname_changed_on_reregister(self, mocked_update_config): + def test_update_config_hostname_changed_on_reregister( + self, mocked_update_config, mocked_launch_command + ): device = self._create_device_config() self._create_device_connection(device=device) # Trigger re-registration with new hostname diff --git a/openwisp_controller/geo/estimated_location/service.py b/openwisp_controller/geo/estimated_location/service.py index a3d661f81..bda07cc5e 100644 --- a/openwisp_controller/geo/estimated_location/service.py +++ b/openwisp_controller/geo/estimated_location/service.py @@ -63,6 +63,8 @@ def is_estimated_location_enabled(self): return self.check_estimated_location_enabled(self.device.organization_id) def trigger_estimated_location_task(self, ip_address): + if self.device.is_deactivated(): + return try: current_app.send_task( "whois_estimated_location_task", From 50a19ae7f8104cae727c86a223b1235fd4716d77 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 14 May 2026 22:43:10 +0530 Subject: [PATCH 05/12] fix(geo): skip queued geo tasks for deactivated devices --- openwisp_controller/config/whois/tasks.py | 3 +++ openwisp_controller/geo/estimated_location/tasks.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/openwisp_controller/config/whois/tasks.py b/openwisp_controller/config/whois/tasks.py index 27532b9fc..b1e3acfcc 100644 --- a/openwisp_controller/config/whois/tasks.py +++ b/openwisp_controller/config/whois/tasks.py @@ -72,6 +72,9 @@ def fetch_whois_details(self, device_pk, initial_ip_address): except Device.DoesNotExist: logger.warning(f"Device {device_pk} not found, skipping WHOIS lookup") return + if device.is_deactivated(): + logger.info(f"Device {device_pk} is deactivated, skipping WHOIS lookup") + return new_ip_address = device.last_ip whois_service = device.whois_service # If there is existing WHOIS older record then it needs to be updated diff --git a/openwisp_controller/geo/estimated_location/tasks.py b/openwisp_controller/geo/estimated_location/tasks.py index 0ae63bdd8..fa634bc75 100644 --- a/openwisp_controller/geo/estimated_location/tasks.py +++ b/openwisp_controller/geo/estimated_location/tasks.py @@ -121,6 +121,11 @@ def manage_estimated_locations(device_pk, ip_address): f"Device {device_pk} not found, skipping manage_estimated_locations" ) return + if device.is_deactivated(): + logger.info( + f"Device {device_pk} is deactivated, skipping estimated location task" + ) + return devices_with_location = list( # "devicelocation" and "devicelocation__location" must be in only() to # prevent Django from deferring them, which would conflict with From c096ace2b5164b97a45219e108715c9e131a177a Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 1 Jun 2026 19:33:45 +0530 Subject: [PATCH 06/12] [fix] Added inline comments and tests --- .../config/controller/views.py | 4 +- openwisp_controller/config/whois/service.py | 6 ++ openwisp_controller/config/whois/tasks.py | 2 + .../config/whois/tests/tests.py | 74 +++++++++++++------ .../connection/tests/test_tasks.py | 5 +- .../geo/estimated_location/service.py | 1 + .../geo/estimated_location/tasks.py | 2 + .../geo/estimated_location/tests/tests.py | 29 ++++++++ 8 files changed, 94 insertions(+), 29 deletions(-) diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py index 93f011757..b2bf40764 100644 --- a/openwisp_controller/config/controller/views.py +++ b/openwisp_controller/config/controller/views.py @@ -93,7 +93,7 @@ def _remove_duplicated_management_ip(self, device): where &= Q(organization_id=device.organization_id) queryset = self.model.objects.filter(where).exclude(pk=device.pk) - for dupe in queryset.only("pk", "key", "management_ip"): + for dupe in queryset.only("pk", "key", "management_ip", "_is_deactivated"): dupe.management_ip = "" dupe.save(update_fields=["management_ip"]) @@ -108,7 +108,7 @@ def _remove_duplicated_last_ip(self, device): where &= Q(organization_id=device.organization_id) queryset = self.model.objects.filter(where).exclude(pk=device.pk) - for dupe in queryset.only("pk", "key", "last_ip"): + for dupe in queryset.only("pk", "key", "last_ip", "_is_deactivated"): dupe.last_ip = "" dupe.save(update_fields=["last_ip"]) diff --git a/openwisp_controller/config/whois/service.py b/openwisp_controller/config/whois/service.py index a98d44dd6..0110ab307 100644 --- a/openwisp_controller/config/whois/service.py +++ b/openwisp_controller/config/whois/service.py @@ -208,8 +208,12 @@ def get_device_whois_info(self): def process_ip_data_and_location(self, force_lookup=False): """ Trigger WHOIS lookup based on the conditions of `_need_whois_lookup`. + Returns early if the device is deactivated. Tasks are triggered on commit to ensure redundant data is not created. """ + # Do not trigger WHOIS fetch for deactivated devices. + # Returning here also suppresses the whois_lookup_skipped signal emitted + # below, so estimated location is not triggered for a deactivated device. if self.device.is_deactivated(): return new_ip = self.device.last_ip @@ -230,7 +234,9 @@ def update_whois_info(self): Update existing WHOIS data for the device when the data is older than ``OPENWISP_CONTROLLER_WHOIS_REFRESH_THRESHOLD_DAYS``. + Returns early if the device is deactivated. """ + # Do not refresh WHOIS data for deactivated devices. if self.device.is_deactivated(): return ip_address = self.device.last_ip diff --git a/openwisp_controller/config/whois/tasks.py b/openwisp_controller/config/whois/tasks.py index b1e3acfcc..ec5a15d4a 100644 --- a/openwisp_controller/config/whois/tasks.py +++ b/openwisp_controller/config/whois/tasks.py @@ -72,6 +72,8 @@ def fetch_whois_details(self, device_pk, initial_ip_address): except Device.DoesNotExist: logger.warning(f"Device {device_pk} not found, skipping WHOIS lookup") return + # Defense in depth: a device can be deactivated after the task is queued, + # so re-check here and do not run the external lookup for it. if device.is_deactivated(): logger.info(f"Device {device_pk} is deactivated, skipping WHOIS lookup") return diff --git a/openwisp_controller/config/whois/tests/tests.py b/openwisp_controller/config/whois/tests/tests.py index f5529ebb2..43bd127df 100644 --- a/openwisp_controller/config/whois/tests/tests.py +++ b/openwisp_controller/config/whois/tests/tests.py @@ -177,9 +177,10 @@ def test_whois_enabled(self): ) with self.subTest("Test WHOIS not configured does not allow enabling WHOIS"): - with mock.patch.object( - app_settings, "WHOIS_CONFIGURED", False - ), self.assertRaises(ValidationError) as context_manager: + with ( + mock.patch.object(app_settings, "WHOIS_CONFIGURED", False), + self.assertRaises(ValidationError) as context_manager, + ): org_settings_obj.full_clean() self.assertEqual( context_manager.exception.message_dict["whois_enabled"][0], @@ -483,9 +484,12 @@ def test_whois_task_called(self, mocked_lookup_task): org.config_settings.save() with self.subTest("WHOIS lookup task called when last_ip is public"): - with mock.patch( - "django.core.cache.cache.get", return_value=None - ) as mocked_get, mock.patch("django.core.cache.cache.set") as mocked_set: + with ( + mock.patch( + "django.core.cache.cache.get", return_value=None + ) as mocked_get, + mock.patch("django.core.cache.cache.set") as mocked_set, + ): device = self._create_device(last_ip="172.217.22.14") mocked_lookup_task.assert_called() mocked_set.assert_called_once_with( @@ -499,9 +503,10 @@ def test_whois_task_called(self, mocked_lookup_task): with self.subTest( "WHOIS lookup task called when last_ip is changed and is public" ): - with mock.patch("django.core.cache.cache.get") as mocked_get, mock.patch( - "django.core.cache.cache.set" - ) as mocked_set: + with ( + mock.patch("django.core.cache.cache.get") as mocked_get, + mock.patch("django.core.cache.cache.set") as mocked_set, + ): device.last_ip = "172.217.22.10" device.save() device.refresh_from_db() @@ -823,11 +828,13 @@ def test_device_last_ip_deferred_checks(self): device._check_last_ip() self.assertTrue(device._is_deferred("last_ip")) - with self.subTest( - "Test update_whois does not run if last_ip is deferred" - ), mock.patch( - "openwisp_controller.config.whois.service.WHOISService.update_whois_info" - ) as mock_update_whois: + with ( + self.subTest("Test update_whois does not run if last_ip is deferred"), + mock.patch( + "openwisp_controller.config.whois.service" + ".WHOISService.update_whois_info" + ) as mock_update_whois, + ): threshold = app_settings.WHOIS_REFRESH_THRESHOLD_DAYS + 1 new_time = timezone.now() - timedelta(days=threshold) WHOISInfo.objects.filter(pk=whois_obj.pk).update(modified=new_time) @@ -871,9 +878,12 @@ def test_whois_task_failure_notification(self, mock_info, mock_warn, mock_error) def assert_logging_on_exception( exception, info_calls=0, warn_calls=0, error_calls=1, notification_count=1 ): - with self.subTest( - f"Test notification and logging when {exception.__name__} is raised" - ), mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client: + with ( + self.subTest( + f"Test notification and logging when {exception.__name__} is raised" + ), + mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client, + ): mock_client.return_value.city.side_effect = exception("test") Device.objects.all().delete() # Clear existing devices device = self._create_device(last_ip="172.217.22.14") @@ -947,9 +957,10 @@ def trigger_error_and_assert_cached(exc, notification_count=0): ): trigger_error_and_assert_cached(subsequent_error, 0) - with self.subTest("Test cache updated on success"), mock.patch( - self._WHOIS_GEOIP_CLIENT - ) as mock_client: + with ( + self.subTest("Test cache updated on success"), + mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client, + ): Device.objects.all().delete() mocked_response = self._mocked_client_response() mocked_response.location = None @@ -965,9 +976,12 @@ def trigger_error_and_assert_cached(exc, notification_count=0): @mock.patch("openwisp_controller.config.whois.tasks.fetch_whois_details.retry") def test_whois_task_retry_mechanism(self, mock_retry): def assert_retry_on_exception(exception, should_retry): - with self.subTest( - f"Test retry mechanism when {exception.__name__} is raised" - ), mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client: + with ( + self.subTest( + f"Test retry mechanism when {exception.__name__} is raised" + ), + mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client, + ): mock_client.return_value.city.side_effect = exception("test") Device.objects.all().delete() mock_retry.reset_mock() @@ -995,6 +1009,20 @@ def test_fetch_whois_details_device_not_found(self, mock_warn): f"Device {invalid_pk} not found, skipping WHOIS lookup" ) + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) + @mock.patch(_WHOIS_TASKS_INFO_LOGGER) + @mock.patch(_WHOIS_GEOIP_CLIENT) + def test_fetch_whois_details_skips_when_deactivated(self, mock_client, mock_info): + whois_obj = self._create_whois_info(ip_address="8.8.8.8") + device = self._create_device(last_ip=whois_obj.ip_address) + mock_client.reset_mock() + device.deactivate() + fetch_whois_details(device_pk=device.pk, initial_ip_address="10.0.0.1") + mock_info.assert_called_once_with( + f"Device {device.pk} is deactivated, skipping WHOIS lookup" + ) + mock_client.assert_not_called() + @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) @mock.patch(_WHOIS_GEOIP_CLIENT) def test_fetch_whois_details_record_already_exists(self, mock_client): diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py index 5bd4a9c71..818e21c4a 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -167,11 +167,8 @@ def test_launch_command_exception(self, *args): class TestTransactionTasks( TestRegistrationMixin, CreateConnectionsMixin, TransactionTestCase ): - @mock.patch("openwisp_controller.connection.tasks.launch_command.delay") @mock.patch.object(tasks.update_config, "delay") - def test_update_config_hostname_changed_on_reregister( - self, mocked_update_config, mocked_launch_command - ): + def test_update_config_hostname_changed_on_reregister(self, mocked_update_config): device = self._create_device_config() self._create_device_connection(device=device) # Trigger re-registration with new hostname diff --git a/openwisp_controller/geo/estimated_location/service.py b/openwisp_controller/geo/estimated_location/service.py index bda07cc5e..86ff5215f 100644 --- a/openwisp_controller/geo/estimated_location/service.py +++ b/openwisp_controller/geo/estimated_location/service.py @@ -63,6 +63,7 @@ def is_estimated_location_enabled(self): return self.check_estimated_location_enabled(self.device.organization_id) def trigger_estimated_location_task(self, ip_address): + # Do not re-derive estimated location for deactivated devices. if self.device.is_deactivated(): return try: diff --git a/openwisp_controller/geo/estimated_location/tasks.py b/openwisp_controller/geo/estimated_location/tasks.py index fa634bc75..e6f2b6363 100644 --- a/openwisp_controller/geo/estimated_location/tasks.py +++ b/openwisp_controller/geo/estimated_location/tasks.py @@ -121,6 +121,8 @@ def manage_estimated_locations(device_pk, ip_address): f"Device {device_pk} not found, skipping manage_estimated_locations" ) return + # Defense in depth: a device can be deactivated after the task is queued, + # so re-check here. if device.is_deactivated(): logger.info( f"Device {device_pk} is deactivated, skipping estimated location task" diff --git a/openwisp_controller/geo/estimated_location/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py index 2218883a2..f96c47abf 100644 --- a/openwisp_controller/geo/estimated_location/tests/tests.py +++ b/openwisp_controller/geo/estimated_location/tests/tests.py @@ -194,6 +194,22 @@ def test_estimated_location_admin_add_whois_disabled(self): response = self.client.get(path) self.assertNotContains(response, "field-is_estimated") + @mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True) + @mock.patch( + "openwisp_controller.geo.estimated_location.service.current_app.send_task" + ) + def test_trigger_estimated_location_task_skips_when_deactivated( + self, mock_send_task + ): + org = self._get_org() + org.geo_settings.estimated_location_enabled = True + org.geo_settings.save() + device = self._create_device(last_ip="172.217.22.14") + device.deactivate() + service = EstimatedLocationService(device) + service.trigger_estimated_location_task(device.last_ip) + mock_send_task.assert_not_called() + class TestEstimatedLocationTransaction( TestEstimatedLocationMixin, WHOISTransactionMixin, TestGeoMixin, TransactionTestCase @@ -797,6 +813,19 @@ def test_manage_estimated_locations_no_coordinates_warning( "Estimated location cannot be determined." ) + @mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True) + @mock.patch(_ESTIMATED_LOCATION_INFO_LOGGER) + def test_manage_estimated_locations_skips_when_deactivated(self, mock_info): + # Create WHOIS info first to prevent eager task from attempting + # a real WHOIS lookup during device creation. + whois_obj = self._create_whois_info(ip_address="172.217.22.14") + device = self._create_device(last_ip=whois_obj.ip_address) + device.deactivate() + manage_estimated_locations(device.pk, device.last_ip) + mock_info.assert_called_once_with( + f"Device {device.pk} is deactivated, skipping estimated location task" + ) + @mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True) @mock.patch( "openwisp_controller.geo.estimated_location.service.current_app.send_task", From 321099d1482e6b4fb7e329314534181a53481dcd Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 1 Jun 2026 20:56:15 +0530 Subject: [PATCH 07/12] [fix] Fixes by @coderabbitai --- openwisp_controller/config/controller/views.py | 7 +++++++ openwisp_controller/config/whois/commands.py | 6 ++++-- openwisp_controller/config/whois/tests/tests.py | 4 +++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py index b2bf40764..9c8bc13c8 100644 --- a/openwisp_controller/config/controller/views.py +++ b/openwisp_controller/config/controller/views.py @@ -94,6 +94,10 @@ def _remove_duplicated_management_ip(self, device): queryset = self.model.objects.filter(where).exclude(pk=device.pk) for dupe in queryset.only("pk", "key", "management_ip", "_is_deactivated"): + # Include _is_deactivated in .only() even though it is not referenced + # in this loop: dupe.save() triggers signal handlers that call + # is_deactivated(), and omitting the field would cause a SELECT + # per save (N+1 query). dupe.management_ip = "" dupe.save(update_fields=["management_ip"]) @@ -109,6 +113,9 @@ def _remove_duplicated_last_ip(self, device): queryset = self.model.objects.filter(where).exclude(pk=device.pk) for dupe in queryset.only("pk", "key", "last_ip", "_is_deactivated"): + # Include _is_deactivated in .only() to avoid N+1 queries: + # dupe.save() triggers signal handlers that call is_deactivated(), + # so the flag is prefetched rather than loaded per row. dupe.last_ip = "" dupe.save(update_fields=["last_ip"]) diff --git a/openwisp_controller/config/whois/commands.py b/openwisp_controller/config/whois/commands.py index 185ef9085..fe4dcb8b7 100644 --- a/openwisp_controller/config/whois/commands.py +++ b/openwisp_controller/config/whois/commands.py @@ -37,8 +37,10 @@ def clear_last_ip_command(stdout, stderr, interactive=True): Device.objects.filter(_is_deactivated=False).select_related( "organization__config_settings" ) - # include the FK field 'organization' in .only() so the related - # `organization__config_settings` traversal is not deferred + # Keep 'organization' and '_is_deactivated' loaded. The former is + # needed for the select_related() traversal, while the latter is + # accessed during save() signal handling via is_deactivated(); + # deferring either field would result in additional queries. .only("last_ip", "organization", "key", "_is_deactivated") ) # Filter out devices that have WHOIS information for their last IP diff --git a/openwisp_controller/config/whois/tests/tests.py b/openwisp_controller/config/whois/tests/tests.py index 43bd127df..8c0d284cd 100644 --- a/openwisp_controller/config/whois/tests/tests.py +++ b/openwisp_controller/config/whois/tests/tests.py @@ -1138,9 +1138,11 @@ def test_process_ip_runs_when_active(self, mock_task): service.process_ip_data_and_location() # transaction.on_commit executes immediately in TransactionTestCase, # so the task is triggered synchronously here + # _initial_last_ip is '8.8.8.8' here because _check_last_ip in + # device.save() set it to device.last_ip after the first save. mock_task.assert_called_once_with( device_pk=device.pk, - initial_ip_address=device._initial_last_ip, + initial_ip_address="8.8.8.8", ) @mock.patch.object(app_settings, "WHOIS_CONFIGURED", True) From 4b1d421a30890930cf7b1a0eb6e64104fdede4e3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 18 May 2026 23:51:58 +0530 Subject: [PATCH 08/12] [change] Added generic enforcement of deactivated devices #1338 Closes #1338 --- openwisp_controller/config/base/config.py | 2 ++ openwisp_controller/config/base/device.py | 12 +++++++----- openwisp_controller/config/base/device_group.py | 2 +- openwisp_controller/config/controller/views.py | 4 +++- openwisp_controller/config/whois/tasks.py | 2 ++ openwisp_controller/connection/apps.py | 2 +- openwisp_controller/connection/base/models.py | 9 ++++++++- openwisp_controller/connection/tasks.py | 3 +++ openwisp_controller/connection/tests/test_api.py | 2 +- .../geo/estimated_location/handlers.py | 8 ++++++-- 10 files changed, 34 insertions(+), 12 deletions(-) diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py index fd1fc01d2..5d8b01b88 100644 --- a/openwisp_controller/config/base/config.py +++ b/openwisp_controller/config/base/config.py @@ -996,6 +996,8 @@ def manage_backend_changed(cls, instance_id, old_backend, backend, **kwargs): Config = load_model("config", "Config") Template = load_model("config", "Template") config = Config.objects.get(pk=instance_id) + if config.is_deactivating_or_deactivated(): + return device_group = config.device.group if not device_group: return diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index bcfff44ca..b570d691f 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -185,6 +185,11 @@ def _get_organization__config_settings(self): def is_deactivated(self): return self._is_deactivated + def is_fully_deactivated(self): + return self.is_deactivated() and ( + not self._has_config() or self.config.is_deactivated() + ) + def deactivate(self): if self.is_deactivated(): # The device has already been deactivated. @@ -299,10 +304,7 @@ def save(self, *args, **kwargs): self._check_changed_fields() def delete(self, using=None, keep_parents=False, check_deactivated=True): - if check_deactivated and ( - not self.is_deactivated() - or (self._has_config() and not self.config.is_deactivated()) - ): + if check_deactivated and (not self.is_fully_deactivated()): raise PermissionDenied("The device must be deactivated prior to deletion") return super().delete(using, keep_parents) @@ -479,7 +481,7 @@ def create_default_config(self, **options): creates a new config instance to apply group templates if group has templates. """ - if not (self.group and self.group.templates.exists()): + if self.is_deactivated() or not (self.group and self.group.templates.exists()): return config = self.get_temp_config_instance( backend=app_settings.DEFAULT_BACKEND, **options diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py index d7f6be31a..7983c1fd9 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/device_group.py @@ -110,7 +110,7 @@ def manage_group_templates(cls, group_id, old_template_ids, template_ids): device_group = DeviceGroup.objects.get(id=group_id) templates = Template.objects.filter(pk__in=template_ids) old_templates = Template.objects.filter(pk__in=old_template_ids) - for device in device_group.device_set.iterator(): + for device in device_group.device_set.exclude(_is_deactivated=True).iterator(): if not hasattr(device, "config"): device.create_default_config() device.config.manage_group_templates(templates, old_templates) diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py index 9c8bc13c8..f3fd8dd93 100644 --- a/openwisp_controller/config/controller/views.py +++ b/openwisp_controller/config/controller/views.py @@ -406,7 +406,9 @@ def post(self, request, *args, **kwargs): # (key is not None only if CONSISTENT_REGISTRATION is enabled) new = False try: - device = self.model.objects.get(key=key) + device = self.model.objects.select_related("config").get(key=key) + if device.is_deactivated(): + return ControllerResponse("error: device deactivated", status=403) # update device info for attr in self.UPDATABLE_FIELDS: if attr in request.POST: diff --git a/openwisp_controller/config/whois/tasks.py b/openwisp_controller/config/whois/tasks.py index ec5a15d4a..de20cd547 100644 --- a/openwisp_controller/config/whois/tasks.py +++ b/openwisp_controller/config/whois/tasks.py @@ -119,6 +119,8 @@ def delete_whois_record(ip_address, force=False): if force: queryset.delete() else: + # Only delete the WHOIS record if there are no active devices + # linked to the same IP address. if ( not Device.objects.filter(_is_deactivated=False) .filter(last_ip=ip_address) diff --git a/openwisp_controller/connection/apps.py b/openwisp_controller/connection/apps.py index f67e326d0..277ce2050 100644 --- a/openwisp_controller/connection/apps.py +++ b/openwisp_controller/connection/apps.py @@ -89,7 +89,7 @@ def _launch_update_config(cls, device): from .tasks import update_config # Check if push update should be skipped - if device.should_skip_push_update(): + if device.is_fully_deactivated() or device.should_skip_push_update(): return update_config.delay(str(device.pk)) diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index cb6d47523..a36f7d6f9 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -141,7 +141,7 @@ def auto_add_to_devices(cls, credential_id, organization_id): DeviceConnection = load_model("connection", "DeviceConnection") Device = load_model("config", "Device") - devices = Device.objects.exclude(config=None) + devices = Device.objects.exclude(config=None).exclude(_is_deactivated=True) if organization_id: devices = devices.filter(organization_id=organization_id) # exclude devices which have been already added @@ -268,6 +268,7 @@ def get_working_connection( ): qs = cls.objects.filter( device=device, + device___is_deactivated=False, enabled=True, credentials__connector=connector, ).order_by("-is_working") @@ -345,6 +346,8 @@ def set_connector(self, connector): def connect(self): try: + if self.device.is_fully_deactivated(): + raise RuntimeError("Device is deactivated") self.connector_instance.connect() except Exception as e: self.is_working = False @@ -378,6 +381,8 @@ def save(self, *args, **kwargs): self._initial_failure_reason = self.failure_reason def send_is_working_changed_signal(self): + if self.device.is_fully_deactivated(): + return is_working_changed.send( sender=self.__class__, is_working=self.is_working, @@ -462,6 +467,8 @@ def __str__(self): return f'«{command}» {sent} {created.strftime("%d %b %Y at %I:%M %p")}' def clean(self): + if self.device.is_deactivated(): + raise ValidationError({"device": _("Device is deactivated.")}) self._verify_command_type_allowed() self._verify_connection() try: diff --git a/openwisp_controller/connection/tasks.py b/openwisp_controller/connection/tasks.py index 56e6a7bb1..a52b826d8 100644 --- a/openwisp_controller/connection/tasks.py +++ b/openwisp_controller/connection/tasks.py @@ -46,6 +46,9 @@ def update_config(self, device_id): time.sleep(2) try: device = Device.objects.select_related("config").get(pk=device_id) + if device.is_fully_deactivated(): + logger.info(f"{device} (pk: {device_id}) is deactivated, skipping update") + return # abort operation if device shouldn't be updated if not device.can_be_updated(): logger.info(f"{device} (pk: {device_id}) is not going to be updated") diff --git a/openwisp_controller/connection/tests/test_api.py b/openwisp_controller/connection/tests/test_api.py index ab94494c1..d7eac7531 100644 --- a/openwisp_controller/connection/tests/test_api.py +++ b/openwisp_controller/connection/tests/test_api.py @@ -287,6 +287,7 @@ def test_endpoints_for_non_existent_device(self): self.assertDictEqual(response.data, device_not_found) def test_endpoints_for_deactivated_device(self): + command = self._create_command(device_conn=self.device_conn) self.device_conn.device.deactivate() with self.subTest("Test listing commands"): @@ -308,7 +309,6 @@ def test_endpoints_for_deactivated_device(self): self.assertEqual(response.status_code, 403) with self.subTest("Test retrieving commands"): - command = self._create_command(device_conn=self.device_conn) url = self._get_path("device_command_details", self.device_id, command.id) response = self.client.get( url, diff --git a/openwisp_controller/geo/estimated_location/handlers.py b/openwisp_controller/geo/estimated_location/handlers.py index bb9ec6314..9af8760da 100644 --- a/openwisp_controller/geo/estimated_location/handlers.py +++ b/openwisp_controller/geo/estimated_location/handlers.py @@ -43,6 +43,7 @@ def whois_fetched_handler(sender, whois, updated_fields, device=None, **kwargs): if ( not updated_fields or not device + or device.is_deactivated() or not EstimatedLocationService.check_estimated_location_enabled( device.organization_id ) @@ -67,8 +68,11 @@ def whois_lookup_skipped_handler(sender, device, **kwargs): Handler for skipped WHOIS lookups. If estimated location is enabled for the device's organization, trigger an estimated location task. """ - if not EstimatedLocationService.check_estimated_location_enabled( - device.organization_id + if ( + device.is_deactivated + or not EstimatedLocationService.check_estimated_location_enabled( + device.organization_id + ) ): return estimated_location_service = EstimatedLocationService(device) From d675cbbba50eafb99f95e17c16e46e52dc39e18b Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 29 May 2026 23:39:22 +0530 Subject: [PATCH 09/12] [fix] Fixed failing tests --- openwisp_controller/config/base/config.py | 5 ++++ openwisp_controller/config/base/device.py | 5 ++++ .../config/tests/test_device.py | 17 +++++++++++ openwisp_controller/connection/base/models.py | 18 +++++++++-- openwisp_controller/connection/tasks.py | 8 +++++ .../connection/tests/test_models.py | 30 +++++++++++++++++++ .../connection/tests/test_tasks.py | 18 +++++++++++ .../geo/estimated_location/handlers.py | 4 ++- openwisp_controller/geo/tests/utils.py | 9 ++++-- .../subnet_division/rule_types/device.py | 14 +++++++-- .../subnet_division/tests/test_models.py | 27 +++++++++++++++++ 11 files changed, 147 insertions(+), 8 deletions(-) diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py index 5d8b01b88..bb61e5ad7 100644 --- a/openwisp_controller/config/base/config.py +++ b/openwisp_controller/config/base/config.py @@ -482,6 +482,11 @@ def certificate_updated(cls, instance, created, **kwargs): except ObjectDoesNotExist: return else: + # Skip deactivated (and deactivating) configs: they are meant to stay + # inert, so a certificate renewal must not recompute their checksum or + # mark them modified again. + if config.is_deactivating_or_deactivated(): + return transaction.on_commit(config.update_status_if_checksum_changed) @classmethod diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index b570d691f..7c86dab14 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -501,6 +501,11 @@ def manage_devices_group_templates(cls, device_ids, old_group_ids, group_id): old_group_ids = [old_group_ids] for device_id, old_group_id in zip(device_ids, old_group_ids): device = Device.objects.get(pk=device_id) + if device.is_deactivated(): + # Skip deactivated devices: their configuration is intentionally + # emptied during deactivation, so re-applying group templates + # would break that state and trigger a push to the device. + continue if not hasattr(device, "config"): device.create_default_config() config_created = hasattr(device, "config") diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index d9bc3f59d..268a5c8a8 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -512,6 +512,23 @@ def test_device_group_changed_not_emitted_on_creation(self): self._create_device(name="test", organization=org, group=device_group) handler.assert_not_called() + def test_manage_devices_group_templates_skips_deactivated_devices(self): + org = self._get_org() + old_template = self._create_template(name="old-template", organization=org) + new_template = self._create_template(name="new-template", organization=org) + old_group = self._create_device_group(name="old-group", organization=org) + new_group = self._create_device_group(name="new-group", organization=org) + old_group.templates.add(old_template) + new_group.templates.add(new_template) + device = self._create_device(name="test", organization=org, group=old_group) + device.deactivate() + device.config.refresh_from_db() + self.assertEqual(device.config.templates.count(), 0) + Device.manage_devices_group_templates(device.pk, old_group.pk, new_group.pk) + device.config.refresh_from_db() + self.assertEqual(device.config.templates.count(), 0) + self.assertNotIn(new_template, device.config.templates.all()) + def test_device_field_changed_checks(self): self._create_device() device_group = self._create_device_group() diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index a36f7d6f9..da958a510 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -141,7 +141,11 @@ def auto_add_to_devices(cls, credential_id, organization_id): DeviceConnection = load_model("connection", "DeviceConnection") Device = load_model("config", "Device") - devices = Device.objects.exclude(config=None).exclude(_is_deactivated=True) + # Deactivated devices are intentionally included: attaching credentials + # is a database-only operation with no network activity (the connection + # cannot be used while the device is deactivated), and having the + # credentials ready avoids extra setup when the device is reactivated. + devices = Device.objects.exclude(config=None) if organization_id: devices = devices.filter(organization_id=organization_id) # exclude devices which have been already added @@ -179,6 +183,9 @@ def auto_add_credentials_to_device(cls, instance, created, **kwargs): if not created: return device = instance.device + # Credentials are attached even when the device is deactivated: this only + # creates DeviceConnection rows (no network operation) and avoids extra + # setup on reactivation. See auto_add_to_devices for the same rationale. # select credentials which # - are flagged as auto_add # - belong to the same organization of the device @@ -266,9 +273,13 @@ def __init__(self, *args, **kwargs): def get_working_connection( cls, device, connector="openwisp_controller.connection.connectors.ssh.Ssh" ): + # Deactivated devices are not filtered out here on purpose: a device that + # is still "deactivating" needs one last connection so the cleared + # configuration can be pushed to it. connect() below refuses fully + # deactivated devices, so only the legitimate deactivating push gets + # through. qs = cls.objects.filter( device=device, - device___is_deactivated=False, enabled=True, credentials__connector=connector, ).order_by("-is_working") @@ -346,6 +357,9 @@ def set_connector(self, connector): def connect(self): try: + # Refuse fully deactivated devices (device deactivated and its config + # already "deactivated"). A device that is only "deactivating" is let + # through so the final cleared configuration can still be pushed. if self.device.is_fully_deactivated(): raise RuntimeError("Device is deactivated") self.connector_instance.connect() diff --git a/openwisp_controller/connection/tasks.py b/openwisp_controller/connection/tasks.py index a52b826d8..a3912c168 100644 --- a/openwisp_controller/connection/tasks.py +++ b/openwisp_controller/connection/tasks.py @@ -79,6 +79,14 @@ def launch_command(command_id): except Command.DoesNotExist as e: logger.warning(f'launch_command("{command_id}") failed: {e}') return + # Do not execute commands on deactivated devices. Creation is already + # rejected by Command.clean(); this guards the case where the device was + # deactivated after the command had been queued. + if command.device.is_deactivated(): + command.status = "failed" + command._add_output(_("Device is deactivated.")) + command.save() + return try: command.execute() except SoftTimeLimitExceeded: diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index 3dac7ffc6..311f7860c 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -349,6 +349,15 @@ def test_auto_add_to_new_device(self): self.assertEqual(d.deviceconnection_set.count(), 1) self.assertEqual(d.deviceconnection_set.first().credentials, c) + def test_auto_add_to_new_deactivated_device(self): + org = Organization.objects.first() + self._create_credentials(auto_add=True, organization=None) + device = self._create_device(organization=org, name="deactivated-device") + device.deactivate() + self._create_config(device=device) + device.refresh_from_db() + self.assertEqual(device.deviceconnection_set.count(), 1) + def test_auto_add_device_missing_config(self): org = Organization.objects.first() self._create_device(organization=org) @@ -1164,6 +1173,27 @@ def test_auto_add_to_existing_device_on_creation(self): self.assertEqual(d.deviceconnection_set.count(), 1) self.assertEqual(d.deviceconnection_set.first().credentials, c) + @mock.patch.object(DeviceConnection, "update_config") + @mock.patch.object(DeviceConnection, "get_working_connection") + @mock.patch("time.sleep") + def test_deactivating_device_update_config( + self, mocked_sleep, mocked_get_working_connection, mocked_update_config + ): + conf = self._prepare_conf_object() + conf.save() + mocked_get_working_connection.reset_mock() + mocked_update_config.reset_mock() + mocked_get_working_connection.return_value = ( + conf.device.deviceconnection_set.first() + ) + + conf.device.deactivate() + + conf.refresh_from_db() + self.assertEqual(conf.status, "deactivating") + mocked_get_working_connection.assert_called_once_with(conf.device) + mocked_update_config.assert_called_once() + def test_chunk_size(self): org = self._get_org() self._create_config(device=self._create_device(organization=org)) diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py index 818e21c4a..d26d713b5 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -163,6 +163,24 @@ def test_launch_command_exception(self, *args): self.assertEqual(command.status, "failed") self.assertEqual(command.output, "Internal system error: test error\n") + @mock.patch(_mock_execute) + def test_launch_command_deactivated_device(self, mocked_execute): + dc = self._create_device_connection() + command = Command( + device=dc.device, + connection=dc, + type="custom", + input={"command": "/usr/sbin/exotic_command"}, + ) + command.full_clean() + command.save() + dc.device.deactivate() + tasks.launch_command.delay(command.pk) + command.refresh_from_db() + self.assertEqual(command.status, "failed") + self.assertEqual(command.output, "Device is deactivated.\n") + mocked_execute.assert_not_called() + class TestTransactionTasks( TestRegistrationMixin, CreateConnectionsMixin, TransactionTestCase diff --git a/openwisp_controller/geo/estimated_location/handlers.py b/openwisp_controller/geo/estimated_location/handlers.py index 9af8760da..b2465e937 100644 --- a/openwisp_controller/geo/estimated_location/handlers.py +++ b/openwisp_controller/geo/estimated_location/handlers.py @@ -68,8 +68,10 @@ def whois_lookup_skipped_handler(sender, device, **kwargs): Handler for skipped WHOIS lookups. If estimated location is enabled for the device's organization, trigger an estimated location task. """ + # Skip deactivated devices: there is no value in estimating a location for + # a device that is no longer managed. if ( - device.is_deactivated + device.is_deactivated() or not EstimatedLocationService.check_estimated_location_enabled( device.organization_id ) diff --git a/openwisp_controller/geo/tests/utils.py b/openwisp_controller/geo/tests/utils.py index a81fcf331..700f672e6 100644 --- a/openwisp_controller/geo/tests/utils.py +++ b/openwisp_controller/geo/tests/utils.py @@ -11,12 +11,12 @@ def _create_organization(self, **kwargs): options = dict(name="org1") options.update(kwargs) options.setdefault("slug", slugify(options["name"])) - if not Organization.objects.filter(**kwargs).count(): + if not Organization.objects.filter(**options).count(): org = Organization(**options) org.full_clean() org.save() else: - org = Organization.objects.get(**kwargs) + org = Organization.objects.get(**options) return org def _add_default_org(self, kwargs): @@ -44,7 +44,10 @@ def _create_floorplan(self, **kwargs): def _create_object_location(self, **kwargs): if "location" not in kwargs: - kwargs["location"] = self._create_location() + location_kwargs = {} + if "content_object" in kwargs: + location_kwargs["organization"] = kwargs["content_object"].organization + kwargs["location"] = self._create_location(**location_kwargs) kwargs["organization"] = kwargs["location"].organization if "content_object" not in kwargs: kwargs["content_object"] = self._create_object( diff --git a/openwisp_controller/subnet_division/rule_types/device.py b/openwisp_controller/subnet_division/rule_types/device.py index 2269cf23a..e2aad84fd 100644 --- a/openwisp_controller/subnet_division/rule_types/device.py +++ b/openwisp_controller/subnet_division/rule_types/device.py @@ -33,6 +33,12 @@ def get_subnet_division_rules(cls, instance): @classmethod def should_create_subnets_ips(cls, instance, **kwargs): + # Skip deactivated devices: provisioning subnets and IP addresses for a + # device that is no longer managed serves no purpose and wastes address + # space. This also covers configs created directly for a device that is + # already deactivated, not just the rule backfill path. + if instance.device.is_deactivated(): + return False return kwargs.get("created", False) @staticmethod @@ -46,7 +52,11 @@ def destroy_provisioned_subnets_ips(instance, **kwargs): def provision_for_existing_objects(cls, rule_obj): for config in ( Config.objects.select_related("device", "device__organization") - .filter(device__organization_id=rule_obj.organization_id) - .iterator() + # Skip deactivated devices: provisioning subnets and IP addresses + # for them serves no purpose and wastes address space. + .filter( + device__organization_id=rule_obj.organization_id, + device___is_deactivated=False, + ).iterator() ): cls.provision_receiver(config, created=True, rule=rule_obj) diff --git a/openwisp_controller/subnet_division/tests/test_models.py b/openwisp_controller/subnet_division/tests/test_models.py index 62e022563..3171a9d2c 100644 --- a/openwisp_controller/subnet_division/tests/test_models.py +++ b/openwisp_controller/subnet_division/tests/test_models.py @@ -815,6 +815,33 @@ def test_device_subnet_division_rule_existing_devices(self): self.ip_query.count(), (rule.number_of_subnets * rule.number_of_ips) ) + def test_device_subnet_division_rule_existing_devices_skips_deactivated(self): + subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude( + id=self.master_subnet.id + ) + self.config.device.deactivate() + self.assertEqual(subnet_query.count(), 0) + self._get_device_subdivision_rule() + self.config.refresh_from_db() + self.assertTrue(self.config.device.is_deactivated()) + self.assertEqual(subnet_query.count(), 0) + self.assertEqual(self.ip_query.count(), 0) + self.assertEqual(self.config.subnetdivisionindex_set.count(), 0) + + def test_device_subnet_division_rule_skips_deactivated_on_config_creation(self): + # A rule already exists; creating a config directly for a device that is + # already deactivated must not provision any subnet or IP. + self._get_device_subdivision_rule() + device = self._create_device( + organization=self.org, + name="deactivated-device", + mac_address="00:11:22:33:44:66", + ) + device.deactivate() + config = self._create_config(device=device) + self.assertTrue(config.device.is_deactivated()) + self.assertEqual(config.subnetdivisionindex_set.count(), 0) + def test_vpn_subnet_division_rule_existing_devices(self): subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude( id=self.master_subnet.id From 82b9640aa8985092689d5b887f31617cbb074238 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 1 Jun 2026 21:39:19 +0530 Subject: [PATCH 10/12] [fix] Fixed conditions --- openwisp_controller/config/base/config.py | 22 ++++++++--------- openwisp_controller/config/base/device.py | 6 ++++- .../config/base/device_group.py | 4 ++-- .../config/tests/test_device.py | 4 ++++ openwisp_controller/connection/apps.py | 2 +- openwisp_controller/connection/base/models.py | 8 +++++++ openwisp_controller/connection/tasks.py | 8 ------- .../connection/tests/test_models.py | 6 +++-- .../connection/tests/test_tasks.py | 8 ++++--- .../subnet_division/rule_types/device.py | 14 ++--------- .../subnet_division/tests/test_models.py | 24 ++++++++++++------- 11 files changed, 57 insertions(+), 49 deletions(-) diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py index bb61e5ad7..aa3cadce8 100644 --- a/openwisp_controller/config/base/config.py +++ b/openwisp_controller/config/base/config.py @@ -482,11 +482,6 @@ def certificate_updated(cls, instance, created, **kwargs): except ObjectDoesNotExist: return else: - # Skip deactivated (and deactivating) configs: they are meant to stay - # inert, so a certificate renewal must not recompute their checksum or - # mark them modified again. - if config.is_deactivating_or_deactivated(): - return transaction.on_commit(config.update_status_if_checksum_changed) @classmethod @@ -862,13 +857,15 @@ def activate(self): self._invalidate_backend_instance_cache() old_checksum = self.checksum self.add_default_templates() - if self.device._get_group(): - self.device.manage_devices_group_templates( - device_ids=self.device.id, - old_group_ids=None, - group_id=self.device.group_id, + if self.device._has_group(): + # Call manage_group_templates directly rather than going through + # manage_devices_group_templates, which would skip the device because + # it is still marked as deactivated at this point in the activation flow. + self.manage_group_templates( + self.device.group.templates.all(), + self.get_template_model().objects.none(), ) - del self.backend_instance + self._invalidate_backend_instance_cache() if old_checksum == self.checksum: # Accelerate activation if the configuration remains # unchanged (i.e. empty configuration) @@ -1001,6 +998,9 @@ def manage_backend_changed(cls, instance_id, old_backend, backend, **kwargs): Config = load_model("config", "Config") Template = load_model("config", "Template") config = Config.objects.get(pk=instance_id) + # All modification operations are blocked on deactivated devices. + # Thus, a user cannot edit the backend for device when it is deactivating. + # Therefore. it will be safe to block this operation here. if config.is_deactivating_or_deactivated(): return device_group = config.device.group diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 7c86dab14..2290a4968 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -481,7 +481,11 @@ def create_default_config(self, **options): creates a new config instance to apply group templates if group has templates. """ - if self.is_deactivated() or not (self.group and self.group.templates.exists()): + if self.is_deactivated(): + # All modification operations are blocked on deactivated devices. + # Hence, default config should not be created for deactivated devices. + return + if not (self.group and self.group.templates.exists()): return config = self.get_temp_config_instance( backend=app_settings.DEFAULT_BACKEND, **options diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py index 7983c1fd9..fe12668c0 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/device_group.py @@ -110,7 +110,7 @@ def manage_group_templates(cls, group_id, old_template_ids, template_ids): device_group = DeviceGroup.objects.get(id=group_id) templates = Template.objects.filter(pk__in=template_ids) old_templates = Template.objects.filter(pk__in=old_template_ids) - for device in device_group.device_set.exclude(_is_deactivated=True).iterator(): - if not hasattr(device, "config"): + for device in device_group.device_set.iterator(): + for device in device_group.device_set.iterator(): device.create_default_config() device.config.manage_group_templates(templates, old_templates) diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index 268a5c8a8..1e2bd4a3e 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -526,8 +526,12 @@ def test_manage_devices_group_templates_skips_deactivated_devices(self): self.assertEqual(device.config.templates.count(), 0) Device.manage_devices_group_templates(device.pk, old_group.pk, new_group.pk) device.config.refresh_from_db() + # Deactivated device config is skipped: adding templates to a cleared + # config would misrepresent what has been applied to the device. self.assertEqual(device.config.templates.count(), 0) self.assertNotIn(new_template, device.config.templates.all()) + # Status must remain "deactivated" — no config push is initiated. + self.assertEqual(device.config.status, "deactivated") def test_device_field_changed_checks(self): self._create_device() diff --git a/openwisp_controller/connection/apps.py b/openwisp_controller/connection/apps.py index 277ce2050..f67e326d0 100644 --- a/openwisp_controller/connection/apps.py +++ b/openwisp_controller/connection/apps.py @@ -89,7 +89,7 @@ def _launch_update_config(cls, device): from .tasks import update_config # Check if push update should be skipped - if device.is_fully_deactivated() or device.should_skip_push_update(): + if device.should_skip_push_update(): return update_config.delay(str(device.pk)) diff --git a/openwisp_controller/connection/base/models.py b/openwisp_controller/connection/base/models.py index da958a510..814cc5b48 100644 --- a/openwisp_controller/connection/base/models.py +++ b/openwisp_controller/connection/base/models.py @@ -554,6 +554,14 @@ def execute(self): raise RuntimeError( "This command has already been executed, " "please create a new one." ) + # Guard against a device that was deactivated after the command was queued. + # Command.clean() already rejects creation for deactivated devices, but + # the device could be deactivated while the task is still pending. + if self.device.is_deactivated(): + self.status = "failed" + self._add_output(_("Device is deactivated.")) + self.save() + return exit_code = self._exec_command() # if output is None, the commands couldn't execute # because the system couldn't connect to the device diff --git a/openwisp_controller/connection/tasks.py b/openwisp_controller/connection/tasks.py index a3912c168..a52b826d8 100644 --- a/openwisp_controller/connection/tasks.py +++ b/openwisp_controller/connection/tasks.py @@ -79,14 +79,6 @@ def launch_command(command_id): except Command.DoesNotExist as e: logger.warning(f'launch_command("{command_id}") failed: {e}') return - # Do not execute commands on deactivated devices. Creation is already - # rejected by Command.clean(); this guards the case where the device was - # deactivated after the command had been queued. - if command.device.is_deactivated(): - command.status = "failed" - command._add_output(_("Device is deactivated.")) - command.save() - return try: command.execute() except SoftTimeLimitExceeded: diff --git a/openwisp_controller/connection/tests/test_models.py b/openwisp_controller/connection/tests/test_models.py index 311f7860c..78a1bb395 100644 --- a/openwisp_controller/connection/tests/test_models.py +++ b/openwisp_controller/connection/tests/test_models.py @@ -1186,9 +1186,11 @@ def test_deactivating_device_update_config( mocked_get_working_connection.return_value = ( conf.device.deviceconnection_set.first() ) - + # Deactivate the device conf.device.deactivate() - + # Ensure that the config status is set to "deactivating" and + # update_config is called to apply the empty configuration for deactivated + # devices. conf.refresh_from_db() self.assertEqual(conf.status, "deactivating") mocked_get_working_connection.assert_called_once_with(conf.device) diff --git a/openwisp_controller/connection/tests/test_tasks.py b/openwisp_controller/connection/tests/test_tasks.py index d26d713b5..82f73b48e 100644 --- a/openwisp_controller/connection/tests/test_tasks.py +++ b/openwisp_controller/connection/tests/test_tasks.py @@ -163,8 +163,10 @@ def test_launch_command_exception(self, *args): self.assertEqual(command.status, "failed") self.assertEqual(command.output, "Internal system error: test error\n") - @mock.patch(_mock_execute) - def test_launch_command_deactivated_device(self, mocked_execute): + @mock.patch( + "openwisp_controller.connection.base.models.AbstractCommand._exec_command" + ) + def test_launch_command_deactivated_device(self, mocked_exec_command): dc = self._create_device_connection() command = Command( device=dc.device, @@ -179,7 +181,7 @@ def test_launch_command_deactivated_device(self, mocked_execute): command.refresh_from_db() self.assertEqual(command.status, "failed") self.assertEqual(command.output, "Device is deactivated.\n") - mocked_execute.assert_not_called() + mocked_exec_command.assert_not_called() class TestTransactionTasks( diff --git a/openwisp_controller/subnet_division/rule_types/device.py b/openwisp_controller/subnet_division/rule_types/device.py index e2aad84fd..2269cf23a 100644 --- a/openwisp_controller/subnet_division/rule_types/device.py +++ b/openwisp_controller/subnet_division/rule_types/device.py @@ -33,12 +33,6 @@ def get_subnet_division_rules(cls, instance): @classmethod def should_create_subnets_ips(cls, instance, **kwargs): - # Skip deactivated devices: provisioning subnets and IP addresses for a - # device that is no longer managed serves no purpose and wastes address - # space. This also covers configs created directly for a device that is - # already deactivated, not just the rule backfill path. - if instance.device.is_deactivated(): - return False return kwargs.get("created", False) @staticmethod @@ -52,11 +46,7 @@ def destroy_provisioned_subnets_ips(instance, **kwargs): def provision_for_existing_objects(cls, rule_obj): for config in ( Config.objects.select_related("device", "device__organization") - # Skip deactivated devices: provisioning subnets and IP addresses - # for them serves no purpose and wastes address space. - .filter( - device__organization_id=rule_obj.organization_id, - device___is_deactivated=False, - ).iterator() + .filter(device__organization_id=rule_obj.organization_id) + .iterator() ): cls.provision_receiver(config, created=True, rule=rule_obj) diff --git a/openwisp_controller/subnet_division/tests/test_models.py b/openwisp_controller/subnet_division/tests/test_models.py index 3171a9d2c..7137d596c 100644 --- a/openwisp_controller/subnet_division/tests/test_models.py +++ b/openwisp_controller/subnet_division/tests/test_models.py @@ -815,22 +815,28 @@ def test_device_subnet_division_rule_existing_devices(self): self.ip_query.count(), (rule.number_of_subnets * rule.number_of_ips) ) - def test_device_subnet_division_rule_existing_devices_skips_deactivated(self): + def test_device_subnet_division_rule_existing_devices_includes_deactivated(self): subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude( id=self.master_subnet.id ) self.config.device.deactivate() self.assertEqual(subnet_query.count(), 0) - self._get_device_subdivision_rule() + rule = self._get_device_subdivision_rule() self.config.refresh_from_db() self.assertTrue(self.config.device.is_deactivated()) - self.assertEqual(subnet_query.count(), 0) - self.assertEqual(self.ip_query.count(), 0) - self.assertEqual(self.config.subnetdivisionindex_set.count(), 0) + # Subnets are provisioned even for deactivated devices so re-activation + # does not require extra provisioning logic. + self.assertEqual(subnet_query.count(), rule.number_of_subnets) + self.assertEqual( + self.ip_query.count(), rule.number_of_subnets * rule.number_of_ips + ) + self.assertGreater(self.config.subnetdivisionindex_set.count(), 0) - def test_device_subnet_division_rule_skips_deactivated_on_config_creation(self): - # A rule already exists; creating a config directly for a device that is - # already deactivated must not provision any subnet or IP. + def test_device_subnet_division_rule_provisions_deactivated_on_config_creation( + self, + ): + # A rule already exists; subnets must be provisioned even when the config + # is created for a device that is already deactivated. self._get_device_subdivision_rule() device = self._create_device( organization=self.org, @@ -840,7 +846,7 @@ def test_device_subnet_division_rule_skips_deactivated_on_config_creation(self): device.deactivate() config = self._create_config(device=device) self.assertTrue(config.device.is_deactivated()) - self.assertEqual(config.subnetdivisionindex_set.count(), 0) + self.assertGreater(config.subnetdivisionindex_set.count(), 0) def test_vpn_subnet_division_rule_existing_devices(self): subnet_query = self.subnet_query.filter(organization_id=self.org.id).exclude( From 4ee5d6074e7269ae34c9c8b053054a88f18932f5 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 1 Jun 2026 21:41:43 +0530 Subject: [PATCH 11/12] [fix] Fixed failing QA checks --- openwisp_controller/config/base/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py index aa3cadce8..3d0a08f17 100644 --- a/openwisp_controller/config/base/config.py +++ b/openwisp_controller/config/base/config.py @@ -1000,7 +1000,7 @@ def manage_backend_changed(cls, instance_id, old_backend, backend, **kwargs): config = Config.objects.get(pk=instance_id) # All modification operations are blocked on deactivated devices. # Thus, a user cannot edit the backend for device when it is deactivating. - # Therefore. it will be safe to block this operation here. + # Therefore. it will be safe to block this operation here. if config.is_deactivating_or_deactivated(): return device_group = config.device.group From 1ae9781132e3e288296b26b87c751a740db3da99 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 1 Jun 2026 22:06:17 +0530 Subject: [PATCH 12/12] [fix] Fixed tests --- openwisp_controller/config/base/device_group.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py index fe12668c0..7983c1fd9 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/device_group.py @@ -110,7 +110,7 @@ def manage_group_templates(cls, group_id, old_template_ids, template_ids): device_group = DeviceGroup.objects.get(id=group_id) templates = Template.objects.filter(pk__in=template_ids) old_templates = Template.objects.filter(pk__in=old_template_ids) - for device in device_group.device_set.iterator(): - for device in device_group.device_set.iterator(): + for device in device_group.device_set.exclude(_is_deactivated=True).iterator(): + if not hasattr(device, "config"): device.create_default_config() device.config.manage_group_templates(templates, old_templates)