Files
netbox/netbox/utilities/tests/test_forms.py
Jason Novinger 17e5184a11 Fixes #20759: Group object types by app in permission form (#20931)
* Fixes #20759: Group object types by app in permission form

Modified the ObjectPermissionForm to use optgroups for organizing
object types by application. This shortens the display names (e.g.,
"permission" instead of "Authentication and Authorization | permission")
while maintaining clear organization through visual grouping.

Changes:
- Updated get_object_types_choices() to return nested optgroup structure
- Enhanced AvailableOptions and SelectedOptions widgets to handle optgroups
- Modified TypeScript moveOptions to preserve optgroup structure
- Added hover text showing full model names
- Styled optgroups with bold, padded labels

* Address PR feedback
2025-12-09 08:43:29 -05:00

553 lines
17 KiB
Python

from django import forms
from django.test import TestCase
from dcim.models import Site
from netbox.choices import ImportFormatChoices
from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.fields.csv import CSVSelectWidget
from utilities.forms.forms import BulkRenameForm
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
class ExpandIPAddress(TestCase):
"""
Validate the operation of expand_ipaddress_pattern().
"""
def test_ipv4_range(self):
input = '1.2.3.[9-10]/32'
output = sorted([
'1.2.3.9/32',
'1.2.3.10/32',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
def test_ipv4_set(self):
input = '1.2.3.[4,44]/32'
output = sorted([
'1.2.3.4/32',
'1.2.3.44/32',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
def test_ipv4_multiple_ranges(self):
input = '1.[9-10].3.[9-11]/32'
output = sorted([
'1.9.3.9/32',
'1.9.3.10/32',
'1.9.3.11/32',
'1.10.3.9/32',
'1.10.3.10/32',
'1.10.3.11/32',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
def test_ipv4_multiple_sets(self):
input = '1.[2,22].3.[4,44]/32'
output = sorted([
'1.2.3.4/32',
'1.2.3.44/32',
'1.22.3.4/32',
'1.22.3.44/32',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
def test_ipv4_set_and_range(self):
input = '1.[2,22].3.[9-11]/32'
output = sorted([
'1.2.3.9/32',
'1.2.3.10/32',
'1.2.3.11/32',
'1.22.3.9/32',
'1.22.3.10/32',
'1.22.3.11/32',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
def test_ipv6_range(self):
input = 'fec::abcd:[9-b]/64'
output = sorted([
'fec::abcd:9/64',
'fec::abcd:a/64',
'fec::abcd:b/64',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
def test_ipv6_range_multichar_field(self):
input = 'fec::abcd:[f-11]/64'
output = sorted([
'fec::abcd:f/64',
'fec::abcd:10/64',
'fec::abcd:11/64',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
def test_ipv6_set(self):
input = 'fec::abcd:[9,ab]/64'
output = sorted([
'fec::abcd:9/64',
'fec::abcd:ab/64',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
def test_ipv6_multiple_ranges(self):
input = 'fec::[1-2]bcd:[9-b]/64'
output = sorted([
'fec::1bcd:9/64',
'fec::1bcd:a/64',
'fec::1bcd:b/64',
'fec::2bcd:9/64',
'fec::2bcd:a/64',
'fec::2bcd:b/64',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
def test_ipv6_multiple_sets(self):
input = 'fec::[a,f]bcd:[9,ab]/64'
output = sorted([
'fec::abcd:9/64',
'fec::abcd:ab/64',
'fec::fbcd:9/64',
'fec::fbcd:ab/64',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
def test_ipv6_set_and_range(self):
input = 'fec::[dead,beaf]:[9-b]/64'
output = sorted([
'fec::dead:9/64',
'fec::dead:a/64',
'fec::dead:b/64',
'fec::beaf:9/64',
'fec::beaf:a/64',
'fec::beaf:b/64',
])
self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
def test_invalid_address_family(self):
with self.assertRaisesRegex(Exception, 'Invalid IP address family: 5'):
sorted(expand_ipaddress_pattern(None, 5))
def test_invalid_non_pattern(self):
with self.assertRaises(ValueError):
sorted(expand_ipaddress_pattern('1.2.3.4/32', 4))
def test_invalid_range(self):
with self.assertRaises(ValueError):
sorted(expand_ipaddress_pattern('1.2.3.[4-]/32', 4))
with self.assertRaises(ValueError):
sorted(expand_ipaddress_pattern('1.2.3.[-4]/32', 4))
with self.assertRaises(ValueError):
sorted(expand_ipaddress_pattern('1.2.3.[4--5]/32', 4))
def test_invalid_range_bounds(self):
self.assertEqual(sorted(expand_ipaddress_pattern('1.2.3.[4-3]/32', 6)), [])
def test_invalid_set(self):
with self.assertRaises(ValueError):
sorted(expand_ipaddress_pattern('1.2.3.[4]/32', 4))
with self.assertRaises(ValueError):
sorted(expand_ipaddress_pattern('1.2.3.[4,]/32', 4))
with self.assertRaises(ValueError):
sorted(expand_ipaddress_pattern('1.2.3.[,4]/32', 4))
with self.assertRaises(ValueError):
sorted(expand_ipaddress_pattern('1.2.3.[4,,5]/32', 4))
class ExpandAlphanumeric(TestCase):
"""
Validate the operation of expand_alphanumeric_pattern().
"""
def test_range_numberic(self):
input = 'r[9-11]a'
output = sorted([
'r9a',
'r10a',
'r11a',
])
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
def test_range_alpha(self):
input = '[r-t]1a'
output = sorted([
'r1a',
's1a',
't1a',
])
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
def test_set_numeric(self):
input = 'r[1,2]a'
output = sorted([
'r1a',
'r2a',
])
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
def test_set_alpha(self):
input = '[r,t]1a'
output = sorted([
'r1a',
't1a',
])
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
def test_set_multichar(self):
input = '[ra,tb]1a'
output = sorted([
'ra1a',
'tb1a',
])
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
def test_multiple_ranges(self):
input = '[r-t]1[a-b]'
output = sorted([
'r1a',
'r1b',
's1a',
's1b',
't1a',
't1b',
])
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
def test_multiple_sets(self):
input = '[ra,tb]1[ax,by]'
output = sorted([
'ra1ax',
'ra1by',
'tb1ax',
'tb1by',
])
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
def test_set_and_range(self):
input = '[ra,tb]1[a-c]'
output = sorted([
'ra1a',
'ra1b',
'ra1c',
'tb1a',
'tb1b',
'tb1c',
])
self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
def test_invalid_non_pattern(self):
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r9a'))
def test_invalid_range(self):
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[8-]a'))
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[-8]a'))
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[8--9]a'))
def test_invalid_range_alphanumeric(self):
self.assertEqual(sorted(expand_alphanumeric_pattern('r[9-a]a')), [])
self.assertEqual(sorted(expand_alphanumeric_pattern('r[a-9]a')), [])
def test_invalid_range_bounds(self):
with self.assertRaises(forms.ValidationError):
sorted(expand_alphanumeric_pattern('r[9-8]a'))
sorted(expand_alphanumeric_pattern('r[b-a]a'))
def test_invalid_range_len(self):
with self.assertRaises(forms.ValidationError):
sorted(expand_alphanumeric_pattern('r[a-bb]a'))
def test_invalid_set(self):
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[a]a'))
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[a,]a'))
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[,a]a'))
with self.assertRaises(ValueError):
sorted(expand_alphanumeric_pattern('r[a,,b]a'))
class ImportFormTest(TestCase):
def test_format_detection(self):
form = BulkImportForm()
data = (
"a,b,c\n"
"1,2,3\n"
"4,5,6\n"
)
self.assertEqual(form._detect_format(data), ImportFormatChoices.CSV)
data = '{"a": 1, "b": 2, "c": 3"}'
self.assertEqual(form._detect_format(data), ImportFormatChoices.JSON)
data = '[{"a": 1, "b": 2, "c": 3"}, {"a": 4, "b": 5, "c": 6"}]'
self.assertEqual(form._detect_format(data), ImportFormatChoices.JSON)
data = (
"- a: 1\n"
" b: 2\n"
" c: 3\n"
"- a: 4\n"
" b: 5\n"
" c: 6\n"
)
self.assertEqual(form._detect_format(data), ImportFormatChoices.YAML)
data = (
"---\n"
"a: 1\n"
"b: 2\n"
"c: 3\n"
"---\n"
"a: 4\n"
"b: 5\n"
"c: 6\n"
)
self.assertEqual(form._detect_format(data), ImportFormatChoices.YAML)
# Invalid data
with self.assertRaises(forms.ValidationError):
form._detect_format('')
with self.assertRaises(forms.ValidationError):
form._detect_format('?')
def test_csv_delimiters(self):
form = BulkImportForm()
data = (
"a,b,c\n"
"1,2,3\n"
"4,5,6\n"
)
self.assertEqual(form._clean_csv(data, delimiter=','), [
{'a': '1', 'b': '2', 'c': '3'},
{'a': '4', 'b': '5', 'c': '6'},
])
data = (
"a;b;c\n"
"1;2;3\n"
"4;5;6\n"
)
self.assertEqual(form._clean_csv(data, delimiter=';'), [
{'a': '1', 'b': '2', 'c': '3'},
{'a': '4', 'b': '5', 'c': '6'},
])
data = (
"a\tb\tc\n"
"1\t2\t3\n"
"4\t5\t6\n"
)
self.assertEqual(form._clean_csv(data, delimiter='\t'), [
{'a': '1', 'b': '2', 'c': '3'},
{'a': '4', 'b': '5', 'c': '6'},
])
class BulkRenameFormTest(TestCase):
def test_no_strip_whitespace(self):
# Tests to make sure Bulk Rename Form isn't stripping whitespaces
# See: https://github.com/netbox-community/netbox/issues/13791
form = BulkRenameForm(data={
"find": " hello ",
"replace": " world "
})
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["find"], " hello ")
self.assertEqual(form.cleaned_data["replace"], " world ")
class GetFieldValueTest(TestCase):
@classmethod
def setUpTestData(cls):
class TestForm(forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False
)
cls.form_class = TestForm
cls.sites = (
Site(name='Test Site 1', slug='test-site-1'),
Site(name='Test Site 2', slug='test-site-2'),
)
Site.objects.bulk_create(cls.sites)
def test_unbound_without_initial(self):
form = self.form_class()
self.assertEqual(
get_field_value(form, 'site'),
None
)
def test_unbound_with_initial(self):
form = self.form_class(initial={'site': self.sites[0].pk})
self.assertEqual(
get_field_value(form, 'site'),
self.sites[0].pk
)
def test_bound_value_without_initial(self):
form = self.form_class({'site': self.sites[0].pk})
self.assertEqual(
get_field_value(form, 'site'),
self.sites[0].pk
)
def test_bound_value_with_initial(self):
form = self.form_class({'site': self.sites[0].pk}, initial={'site': self.sites[1].pk})
self.assertEqual(
get_field_value(form, 'site'),
self.sites[0].pk
)
def test_bound_null_without_initial(self):
form = self.form_class({'site': None})
self.assertEqual(
get_field_value(form, 'site'),
None
)
def test_bound_null_with_initial(self):
form = self.form_class({'site': None}, initial={'site': self.sites[1].pk})
self.assertEqual(
get_field_value(form, 'site'),
None
)
class CSVSelectWidgetTest(TestCase):
"""
Validate that CSVSelectWidget treats blank values as omitted.
This allows model defaults to be applied when CSV fields are present but empty.
Related to issue #20645.
"""
def test_blank_value_treated_as_omitted(self):
"""Test that blank string values are treated as omitted"""
widget = CSVSelectWidget()
data = {'test_field': ''}
self.assertTrue(widget.value_omitted_from_data(data, {}, 'test_field'))
def test_none_value_treated_as_omitted(self):
"""Test that None values are treated as omitted"""
widget = CSVSelectWidget()
data = {'test_field': None}
self.assertTrue(widget.value_omitted_from_data(data, {}, 'test_field'))
def test_missing_field_treated_as_omitted(self):
"""Test that missing fields are treated as omitted"""
widget = CSVSelectWidget()
data = {}
self.assertTrue(widget.value_omitted_from_data(data, {}, 'test_field'))
def test_valid_value_not_omitted(self):
"""Test that valid values are not treated as omitted"""
widget = CSVSelectWidget()
data = {'test_field': 'valid_value'}
self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
class SelectMultipleWidgetTest(TestCase):
"""
Validate filtering behavior of AvailableOptions and SelectedOptions widgets.
"""
def test_available_options_flat_choices(self):
"""AvailableOptions should exclude selected values from flat choices"""
widget = AvailableOptions(choices=[
(1, 'Option 1'),
(2, 'Option 2'),
(3, 'Option 3'),
])
widget.optgroups('test', ['2'], None)
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0], (1, 'Option 1'))
self.assertEqual(widget.choices[1], (3, 'Option 3'))
def test_available_options_optgroups(self):
"""AvailableOptions should exclude selected values from optgroups"""
widget = AvailableOptions(choices=[
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
])
# Select options 2 and 3
widget.optgroups('test', ['2', '3'], None)
# Should have 2 groups with filtered choices
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0][0], 'Group A')
self.assertEqual(widget.choices[0][1], [(1, 'Option 1')])
self.assertEqual(widget.choices[1][0], 'Group B')
self.assertEqual(widget.choices[1][1], [(4, 'Option 4')])
def test_selected_options_flat_choices(self):
"""SelectedOptions should include only selected values from flat choices"""
widget = SelectedOptions(choices=[
(1, 'Option 1'),
(2, 'Option 2'),
(3, 'Option 3'),
])
# Select option 2
widget.optgroups('test', ['2'], None)
# Should only have option 2
self.assertEqual(len(widget.choices), 1)
self.assertEqual(widget.choices[0], (2, 'Option 2'))
def test_selected_options_optgroups(self):
"""SelectedOptions should include only selected values from optgroups"""
widget = SelectedOptions(choices=[
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
])
# Select options 2 and 3
widget.optgroups('test', ['2', '3'], None)
# Should have 2 groups with only selected choices
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0][0], 'Group A')
self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
self.assertEqual(widget.choices[1][0], 'Group B')
self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])