Skip to content

Commit 7ea2118

Browse files
committed
[fix] Fix duplicate template entries in Device admin
Bug: The JS logic in relevant_templates.js assumed that the last `ul.sortedm2m-items` element belonged to the empty inline form used by Django admin formsets. This assumption breaks when the user does not have permission to add Config objects: in that case the ConfigInlineAdmin does not render the empty form. As a result, both selectors ended up referencing the same list and the script appended template elements twice to the same `sortedm2m` list, causing duplicate entries and issues when saving the form. Fix: Select the empty form container explicitly using `#config-empty ul.sortedm2m-items` instead of relying on the last occurrence of `ul.sortedm2m-items`. This ensures the logic works correctly regardless of whether the empty inline form is rendered. [backport 1.2]
1 parent 45b24b6 commit 7ea2118

File tree

2 files changed

+106
-3
lines changed

2 files changed

+106
-3
lines changed

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_selenium.py

Lines changed: 105 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,100 @@ 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+
4. Default template is automatically selected
409+
"""
410+
# Define permission codenames for the custom group
411+
permission_codenames = [
412+
"view_group",
413+
"change_config",
414+
"view_config",
415+
"add_device",
416+
"change_device",
417+
"delete_device",
418+
"view_device",
419+
"view_devicegroup",
420+
"view_template",
421+
]
422+
# Create a custom group with the specified permissions
423+
permissions = Permission.objects.filter(codename__in=permission_codenames)
424+
custom_group, _ = Group.objects.get_or_create(name="Custom Operator")
425+
custom_group.permissions.set(permissions)
426+
# Create a user and assign the custom group
427+
user = self._create_user(
428+
username="limited_user",
429+
password="testpass123",
430+
email="limited@test.com",
431+
is_staff=True,
432+
)
433+
user.groups.add(custom_group)
434+
org = self._get_org()
435+
self._create_org_user(user=user, organization=org, is_admin=True)
436+
# Create multiple shared templates (organization=None)
437+
template1 = self._create_template(
438+
name="Shared Template 1", organization=None, default=True
439+
)
440+
template2 = self._create_template(name="Shared Template 2", organization=None)
441+
device = self._create_config(organization=org).device
442+
# Login as the limited user
443+
self.login(username="limited_user", password="testpass123")
444+
# Navigate using Selenium
445+
self.open(
446+
reverse("admin:config_device_change", args=[device.id]) + "#config-group"
447+
)
448+
self.hide_loading_overlay()
449+
with self.subTest("All shared templates should be visible"):
450+
self._verify_templates_visibility(visible=[template1, template2])
451+
452+
with self.subTest("Verify sortedm2m list has exactly 2 template items"):
453+
# Check that ul.sortedm2m-items.sortedm2m.ui-sortable has exactly 2 children
454+
# with .sortedm2m-item class
455+
sortedm2m_items = self.find_elements(
456+
by=By.CSS_SELECTOR,
457+
value="ul.sortedm2m-items.sortedm2m.ui-sortable > li.sortedm2m-item",
458+
)
459+
self.assertEqual(
460+
len(sortedm2m_items),
461+
2,
462+
(
463+
"Expected exactly 2 template items in sortedm2m list,"
464+
f" found {len(sortedm2m_items)}"
465+
),
466+
)
467+
468+
with self.subTest(
469+
"Verify checkbox HTML elements are present in page source"
470+
" with correct attributes"
471+
):
472+
page_source = self.web_driver.page_source
473+
# Verify HTML element strings for each template
474+
for idx, template_id in enumerate([template1.id, template2.id], start=0):
475+
html_element = (
476+
f'<input type="checkbox" value="{template_id}" '
477+
f'id="id_config-templates_{idx}" class="sortedm2m"'
478+
' data-required="false"'
479+
)
480+
self.assertIn(
481+
html_element,
482+
page_source,
483+
(
484+
f"Expected checkbox HTML for template {template_id} not found"
485+
" in page source"
486+
),
487+
)
488+
489+
with self.subTest("Save operation completes successfully"):
490+
# Scroll to the top of the page to ensure the save button is visible
491+
self.web_driver.execute_script("window.scrollTo(0, 0);")
492+
self.find_element(by=By.NAME, value="_save").click()
493+
self.wait_for_presence(By.CSS_SELECTOR, ".messagelist .success", timeout=5)
494+
392495

393496
@tag("selenium_tests")
394497
class TestDeviceGroupAdmin(

0 commit comments

Comments
 (0)