diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py index 366d61667..b331c79e3 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/device_group.py @@ -15,7 +15,10 @@ from .. import settings as app_settings from ..signals import group_templates_changed from ..sortedm2m.fields import SortedManyToManyField -from ..tasks import bulk_invalidate_config_get_cached_checksum +from ..tasks import ( + bulk_invalidate_config_get_cached_checksum, + invalidate_controller_views_for_group, +) from .config import TemplatesThrough @@ -88,6 +91,7 @@ def save( bulk_invalidate_config_get_cached_checksum.delay( {"device__group_id": str(self.id)} ) + invalidate_controller_views_for_group.delay(str(self.id)) def get_context(self): return deepcopy(self.context) diff --git a/openwisp_controller/config/base/multitenancy.py b/openwisp_controller/config/base/multitenancy.py index c2434e413..ef4f8a95f 100644 --- a/openwisp_controller/config/base/multitenancy.py +++ b/openwisp_controller/config/base/multitenancy.py @@ -12,7 +12,10 @@ from .. import settings as app_settings from ..exceptions import OrganizationDeviceLimitExceeded -from ..tasks import bulk_invalidate_config_get_cached_checksum +from ..tasks import ( + bulk_invalidate_config_get_cached_checksum, + invalidate_controller_views_cache, +) class AbstractOrganizationConfigSettings(UUIDModel): @@ -100,6 +103,7 @@ def save( bulk_invalidate_config_get_cached_checksum.delay( {"device__organization_id": str(self.organization_id)} ) + invalidate_controller_views_cache.delay(str(self.organization_id)) class AbstractOrganizationLimits(models.Model): diff --git a/openwisp_controller/config/tasks.py b/openwisp_controller/config/tasks.py index 798e40f8c..b0c6e2b8c 100644 --- a/openwisp_controller/config/tasks.py +++ b/openwisp_controller/config/tasks.py @@ -217,3 +217,19 @@ def invalidate_controller_views_cache(organization_id): Vpn.objects.filter(organization_id=organization_id).only("id").iterator() ): GetVpnView.invalidate_get_vpn_cache(vpn) + + +@shared_task(base=OpenwispCeleryTask) +def invalidate_controller_views_for_group(group_id): + """ + Invalidates the DeviceChecksumView cache only for devices in the given group. + + Unlike invalidate_controller_views_cache, this is scoped to a single device + group and does not invalidate VPN caches. + """ + from .controller.views import DeviceChecksumView + + Device = load_model("config", "Device") + + for device in Device.objects.filter(group_id=group_id).only("id").iterator(): + DeviceChecksumView.invalidate_get_device_cache(device) diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index d9bc3f59d..55292ce1f 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -433,6 +433,67 @@ def test_changing_group_variable_invalidates_cache(self): new_checksum = config.get_cached_checksum() self.assertNotEqual(old_checksum, new_checksum) + def test_status_update_on_org_variable_change(self): + org = self._get_org() + cs = OrganizationConfigSettings.objects.create(organization=org, context={}) + c1 = self._create_config(organization=org) + c1.templates.add( + self._create_template( + name="t-with-var", + config={"interfaces": [{"name": "{{ ssid }}", "type": "ethernet"}]}, + default_values={"ssid": "eth0"}, + ) + ) + c1.set_status_applied() + d2 = self._create_device( + organization=org, name="d2", mac_address="00:11:22:33:44:56" + ) + c2 = self._create_config(device=d2) + c2.set_status_applied() + cs.context = {"ssid": "OrgA"} + cs.full_clean() + cs.save() + c1.refresh_from_db() + c2.refresh_from_db() + with self.subTest("affected config changes to modified"): + self.assertEqual(c1.status, "modified") + with self.subTest("unaffected config remains applied"): + self.assertEqual(c2.status, "applied") + + def test_status_update_on_group_variable_change(self): + org = self._get_org() + dg = self._create_device_group(organization=org, context={}) + d1 = self._create_device(organization=org, group=dg) + c1 = self._create_config(device=d1) + c1.templates.add( + self._create_template( + name="t-with-var", + config={"interfaces": [{"name": "{{ ssid }}", "type": "ethernet"}]}, + default_values={"ssid": "eth0"}, + ) + ) + c1.set_status_applied() + d2 = self._create_device( + organization=org, group=dg, name="d2", mac_address="00:11:22:33:44:56" + ) + c2 = self._create_config(device=d2) + c2.set_status_applied() + dg.context = {"ssid": "OrgA"} + dg.full_clean() + patch_path = ( + "openwisp_controller.config.base.device_group" + ".invalidate_controller_views_for_group" + ) + with mock.patch(patch_path) as mocked_task: + dg.save() + mocked_task.delay.assert_called_once_with(str(dg.id)) + c1.refresh_from_db() + c2.refresh_from_db() + with self.subTest("affected config changes to modified"): + self.assertEqual(c1.status, "modified") + with self.subTest("unaffected config remains applied"): + self.assertEqual(c2.status, "applied") + def test_management_ip_changed_not_emitted_on_creation(self): with catch_signal(management_ip_changed) as handler: self._create_device(organization=self._get_org())