Skip to content

Commit 8f03683

Browse files
authored
Merge branch 'master' into fix/1221-vpn-peer-cache-desync
2 parents 2093d3a + 0d17acd commit 8f03683

File tree

9 files changed

+296
-20
lines changed

9 files changed

+296
-20
lines changed

openwisp_controller/config/base/device.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,9 @@ def _get_initial_values_for_checked_fields(self):
338338
if not present_values:
339339
return
340340
self.refresh_from_db(fields=present_values.keys())
341-
for field in self._changed_checked_fields:
342-
setattr(self, f"_initial_{field}", field)
343-
setattr(self, field, present_values[field])
341+
for field, value in present_values.items():
342+
setattr(self, f"_initial_{field}", getattr(self, field))
343+
setattr(self, field, value)
344344

345345
def _check_name_changed(self):
346346
if self._initial_name == models.DEFERRED:

openwisp_controller/config/static/config/js/relevant_templates.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ django.jQuery(function ($) {
138138
resetTemplateOptions();
139139
var enabledTemplates = [],
140140
sortedm2mUl = $("ul.sortedm2m-items:first"),
141-
sortedm2mPrefixUl = $("ul.sortedm2m-items:last");
141+
sortedm2mPrefixUl = $("#config-empty ul.sortedm2m-items");
142142

143143
// Adds "li" elements for templates
144144
Object.keys(data).forEach(function (templateId, index) {

openwisp_controller/config/tests/test_device.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,20 @@ def test_changed_checked_fields_no_duplicates(self):
541541
device.__init__()
542542
self.assertEqual(device._changed_checked_fields.count("last_ip"), 1)
543543

544+
def test_deferred_fields_populated_correctly(self):
545+
device = self._create_device(
546+
name="deferred-test",
547+
management_ip="10.0.0.1",
548+
)
549+
# Load the instance with deferred fields omitted
550+
device = Device.objects.only("id").get(pk=device.pk)
551+
device.management_ip = "10.0.0.55"
552+
# Saving the device object will populate the deferred fields
553+
device.save()
554+
# Ensure `_initial_<field>` contains the actual value, not the field name
555+
self.assertEqual(getattr(device, "_initial_management_ip"), "10.0.0.55")
556+
self.assertNotEqual(getattr(device, "_initial_management_ip"), "management_ip")
557+
544558
def test_exceed_organization_device_limit(self):
545559
org = self._get_org()
546560
org.config_limits.device_limit = 1

openwisp_controller/config/tests/test_selenium.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import time
33

4+
from django.contrib.auth.models import Group, Permission
45
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
56
from django.test import tag
67
from django.urls.base import reverse
@@ -45,9 +46,17 @@ def _verify_templates_visibility(self, hidden=None, visible=None):
4546
hidden = hidden or []
4647
visible = visible or []
4748
for template in hidden:
48-
self.wait_for_invisibility(By.XPATH, f'//*[@value="{template.id}"]')
49+
self.wait_for_invisibility(
50+
By.XPATH,
51+
f'//ul[contains(@class,"sortedm2m-items")]'
52+
f'//input[@value="{template.id}"]',
53+
)
4954
for template in visible:
50-
self.wait_for_visibility(By.XPATH, f'//*[@value="{template.id}"]')
55+
self.wait_for_visibility(
56+
By.XPATH,
57+
f'//ul[contains(@class,"sortedm2m-items")]'
58+
f'//input[@value="{template.id}"]',
59+
)
5160

5261

5362
@tag("selenium_tests")
@@ -389,6 +398,94 @@ def test_add_remove_templates(self):
389398
self.assertEqual(config.templates.count(), 0)
390399
self.assertEqual(config.status, "modified")
391400

401+
def test_relevant_templates_duplicates(self):
402+
"""
403+
Test that a user with specific permissions can see shared templates
404+
properly. Verifies that:
405+
1. User with custom group permissions can access the admin
406+
2. Multiple shared templates are displayed correctly
407+
3. Each template appears only once in the sortedm2m list
408+
"""
409+
# Define permission codenames for the custom group
410+
permission_codenames = [
411+
"view_group",
412+
"change_config",
413+
"view_config",
414+
"add_device",
415+
"change_device",
416+
"delete_device",
417+
"view_device",
418+
"view_devicegroup",
419+
"view_template",
420+
]
421+
# Create a custom group with the specified permissions
422+
permissions = Permission.objects.filter(codename__in=permission_codenames)
423+
custom_group, _ = Group.objects.get_or_create(name="Custom Operator")
424+
custom_group.permissions.set(permissions)
425+
# Create a user and assign the custom group
426+
user = self._create_user(
427+
username="limited_user",
428+
password="testpass123",
429+
email="limited@test.com",
430+
is_staff=True,
431+
)
432+
user.groups.add(custom_group)
433+
org = self._get_org()
434+
self._create_org_user(user=user, organization=org, is_admin=True)
435+
# Create multiple shared templates (organization=None)
436+
template1 = self._create_template(
437+
name="Shared Template 1", organization=None, default=True
438+
)
439+
template2 = self._create_template(name="Shared Template 2", organization=None)
440+
device = self._create_config(organization=org).device
441+
# Login as the limited user
442+
self.login(username="limited_user", password="testpass123")
443+
# Navigate using Selenium
444+
self.open(
445+
reverse(f"admin:{self.config_app_label}_device_change", args=[device.id])
446+
+ "#config-group"
447+
)
448+
self.hide_loading_overlay()
449+
with self.subTest(
450+
"Regression precondition: empty Config inline is not rendered"
451+
):
452+
self.assertFalse(self.web_driver.find_elements(By.ID, "config-empty"))
453+
454+
with self.subTest("All shared templates should be visible"):
455+
self._verify_templates_visibility(visible=[template1, template2])
456+
457+
with self.subTest("Verify sortedm2m list has exactly 2 template items"):
458+
# Check that ul.sortedm2m-items.sortedm2m.ui-sortable has exactly 2 children
459+
# with .sortedm2m-item class
460+
sortedm2m_items = self.find_elements(
461+
by=By.CSS_SELECTOR,
462+
value="ul.sortedm2m-items.sortedm2m.ui-sortable > li.sortedm2m-item",
463+
)
464+
self.assertEqual(
465+
len(sortedm2m_items),
466+
2,
467+
(
468+
"Expected exactly 2 template items in sortedm2m list,"
469+
f" found {len(sortedm2m_items)}"
470+
),
471+
)
472+
473+
with self.subTest(
474+
"Verify checkbox inputs are rendered with expected attributes"
475+
):
476+
for idx, template_id in enumerate([template1.id, template2.id]):
477+
checkbox = self.find_element(
478+
by=By.ID, value=f"id_config-templates_{idx}"
479+
)
480+
self.assertEqual(checkbox.get_attribute("value"), str(template_id))
481+
self.assertEqual(checkbox.get_attribute("data-required"), "false")
482+
483+
with self.subTest("Save operation completes successfully"):
484+
# Scroll to the top of the page to ensure the save button is visible
485+
self.web_driver.execute_script("window.scrollTo(0, 0);")
486+
self.find_element(by=By.NAME, value="_save").click()
487+
self.wait_for_presence(By.CSS_SELECTOR, ".messagelist .success", timeout=5)
488+
392489

393490
@tag("selenium_tests")
394491
class TestDeviceGroupAdmin(

openwisp_controller/geo/tests/test_admin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ def setUp(self):
2929
"""override TestAdminMixin.setUp"""
3030
pass
3131

32+
def _get_location_add_params(self, **kwargs):
33+
params = super()._get_location_add_params(**kwargs)
34+
if "organization" not in kwargs:
35+
params["organization"] = self._get_org().id
36+
return params
37+
3238
def _create_multitenancy_test_env(self, vpn=False):
3339
org1 = self._create_organization(name="test1org")
3440
org2 = self._create_organization(name="test2org")

openwisp_controller/geo/tests/test_selenium.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ def setUp(self):
9191
def test_unsaved_changes_readonly(self):
9292
self.login()
9393
ol = self._create_object_location()
94-
path = reverse("admin:config_device_change", args=[ol.device.id])
94+
path = reverse(
95+
f"admin:{self.object_model._meta.app_label}_"
96+
f"{self.object_model._meta.model_name}_change",
97+
args=[ol.device.id],
98+
)
9599

96100
with self.subTest("Alert should not be displayed without any change"):
97101
self.open(path)

openwisp_controller/subnet_division/rule_types/base.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,36 @@ def _provision_receiver():
7979
def destroyer_receiver(cls, instance, **kwargs):
8080
cls.destroy_provisioned_subnets_ips(instance, **kwargs)
8181

82-
@staticmethod
83-
def post_provision_handler(instance, provisioned, **kwargs):
82+
@classmethod
83+
def post_provision_handler(cls, instance, provisioned, **kwargs):
8484
"""
85-
This method should be overridden in inherited rule types to
86-
perform any operation on provisioned subnets and IP addresses.
87-
:param instance: object that triggered provisioning
88-
:param provisioned: dictionary containing subnets and IP addresses
89-
provisioned, None if nothing is provisioned
85+
Hook for post-provisioning actions on subnets and IP addresses.
86+
87+
This method is intended to be extended by subclasses of rule types
88+
to perform custom operations after subnets and IPs are provisioned.
89+
90+
Subnet provisioning is executed asynchronously in Celery workers.
91+
If the device configuration references variables provided by the
92+
subnet division rule, the current checksum may have been computed
93+
using variable names instead of their provisioned values. In such cases,
94+
`Config.checksum_db` (which tracks persisted configuration changes)
95+
must be updated to reflect the actual provisioned values, and the
96+
checksum cache invalidated to avoid stale data.
97+
98+
:param instance: The object that triggered the provisioning.
99+
:param provisioned: Dictionary containing provisioned subnets and IPs,
100+
or None if no provisioning occurred.
90101
"""
91-
pass
102+
if not provisioned:
103+
return
104+
config = cls.get_config(instance)
105+
config._invalidate_backend_instance_cache()
106+
current_checksum = config.checksum
107+
if current_checksum != config.checksum_db:
108+
# Update checksum using the UPDATE query to avoid sending
109+
# unnecessary signals that may be triggered by `save()` method.
110+
config._update_checksum_db(current_checksum)
111+
config.invalidate_checksum_cache()
92112

93113
@staticmethod
94114
def subnet_provisioned_signal_emitter(instance, provisioned):

openwisp_controller/subnet_division/rule_types/vpn.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ def provision_for_existing_objects(cls, rule_obj):
4141
for vpn_client in qs:
4242
cls.provision_receiver(instance=vpn_client, created=True)
4343

44-
@staticmethod
45-
def post_provision_handler(instance, provisioned, **kwargs):
44+
@classmethod
45+
def post_provision_handler(cls, instance, provisioned, **kwargs):
46+
super().post_provision_handler(instance, provisioned, **kwargs)
4647
# Assign the first provisioned IP address to the VPNClient
4748
# only when subnets and IPs have been provisioned
4849
if provisioned and provisioned["ip_addresses"]:

0 commit comments

Comments
 (0)