Merge branch 'main' into feature

This commit is contained in:
Jeremy Stretch
2025-10-14 13:54:47 -04:00
81 changed files with 5742 additions and 5166 deletions
+55 -16
View File
@@ -1,7 +1,8 @@
import decimal
from django.db.backends.postgresql.psycopg_any import NumericRange
from itertools import count, groupby
from django.db.backends.postgresql.psycopg_any import NumericRange
__all__ = (
'array_to_ranges',
'array_to_string',
@@ -10,6 +11,7 @@ __all__ = (
'drange',
'flatten_dict',
'ranges_to_string',
'ranges_to_string_list',
'shallow_compare_dict',
'string_to_ranges',
)
@@ -73,8 +75,10 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
def array_to_ranges(array):
"""
Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
single-item tuples. For example:
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
single-item tuples.
Example:
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
"""
group = (
list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
@@ -87,7 +91,8 @@ def array_to_ranges(array):
def array_to_string(array):
"""
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
For example:
Example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
ret = []
@@ -135,26 +140,60 @@ def check_ranges_overlap(ranges):
return False
def ranges_to_string_list(ranges):
"""
Convert numeric ranges to a list of display strings.
Each range is rendered as "lower-upper" or "lower" (for singletons).
Bounds are normalized to inclusive values using ``lower_inc``/``upper_inc``.
This underpins ``ranges_to_string()``, which joins the result with commas.
Example:
[NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)] => ["1-5", "8", "10-12"]
"""
if not ranges:
return []
output: list[str] = []
for r in ranges:
# Compute inclusive bounds regardless of how the DB range is stored.
lower = r.lower if r.lower_inc else r.lower + 1
upper = r.upper if r.upper_inc else r.upper - 1
output.append(f"{lower}-{upper}" if lower != upper else str(lower))
return output
def ranges_to_string(ranges):
"""
Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example:
[[1, 100)], [200, 300)] => "1-99,200-299"
Converts a list of ranges into a string representation.
This function takes a list of range objects and produces a string
representation of those ranges. Each range is represented as a
hyphen-separated pair of lower and upper bounds, with inclusive or
exclusive bounds adjusted accordingly. If the lower and upper bounds
of a range are the same, only the single value is added to the string.
Intended for use with ArrayField.
Example:
[NumericRange(1, 5), NumericRange(8, 9), NumericRange(10, 12)] => "1-5,8,10-12"
"""
if not ranges:
return ''
output = []
for r in ranges:
lower = r.lower if r.lower_inc else r.lower + 1
upper = r.upper if r.upper_inc else r.upper - 1
output.append(f'{lower}-{upper}')
return ','.join(output)
return ','.join(ranges_to_string_list(ranges))
def string_to_ranges(value):
"""
Given a string in the format "1-100, 200-300" return an list of NumericRanges. Intended for use with ArrayField.
For example:
"1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)]
Converts a string representation of numeric ranges into a list of NumericRange objects.
This function parses a string containing numeric values and ranges separated by commas (e.g.,
"1-5,8,10-12") and converts it into a list of NumericRange objects.
In the case of a single integer, it is treated as a range where the start and end
are equal. The returned ranges are represented as half-open intervals [lower, upper).
Intended for use with ArrayField.
Example:
"1-5,8,10-12" => [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)]
"""
if not value:
return None
@@ -172,5 +211,5 @@ def string_to_ranges(value):
upper = dash_range[1]
else:
return None
values.append(NumericRange(int(lower), int(upper), bounds='[]'))
values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
return values
@@ -1,4 +1,8 @@
import logging
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
from django import template
from django.templatetags.static import static
from extras.choices import CustomFieldTypeChoices
from utilities.querydict import dict_to_querydict
@@ -10,6 +14,7 @@ __all__ = (
'customfield_value',
'htmx_table',
'formaction',
'static_with_params',
'tag',
)
@@ -124,3 +129,53 @@ def formaction(context):
with 'hx-push-url="true" hx-post' for HTMX navigation.
"""
return 'formaction'
@register.simple_tag
def static_with_params(path, **params):
"""
Generate a static URL with properly appended query parameters.
The original Django static tag doesn't properly handle appending new parameters to URLs
that already contain query parameters, which can result in malformed URLs with double
question marks. This template tag handles the case where static files are served from
AWS S3 or other CDNs that automatically append query parameters to URLs.
This implementation correctly appends new parameters to existing URLs and checks for
parameter conflicts. A warning will be logged if any of the provided parameters
conflict with existing parameters in the URL.
Args:
path: The static file path (e.g., 'setmode.js')
**params: Query parameters to append (e.g., v='4.3.1')
Returns:
A properly formatted URL with query parameters.
Note:
If any provided parameters conflict with existing URL parameters, a warning
will be logged and the new parameter value will override the existing one.
"""
# Get the base static URL
static_url = static(path)
# Parse the URL to extract existing query parameters
parsed = urlparse(static_url)
existing_params = parse_qs(parsed.query)
# Check for duplicate parameters and log warnings
logger = logging.getLogger('netbox.utilities.templatetags.tags')
for key, value in params.items():
if key in existing_params:
logger.warning(
f"Parameter '{key}' already exists in static URL '{static_url}' "
f"with value(s) {existing_params[key]}, overwriting with '{value}'"
)
existing_params[key] = [str(value)]
# Rebuild the query string
new_query = urlencode(existing_params, doseq=True)
# Reconstruct the URL with the new query string
new_parsed = parsed._replace(query=new_query)
return urlunparse(new_parsed)
+2 -3
View File
@@ -149,14 +149,13 @@ class APIPaginationTestCase(APITestCase):
def test_default_page_size_with_small_max_page_size(self):
response = self.client.get(self.url, format='json', **self.header)
page_size = get_config().MAX_PAGE_SIZE
paginate_count = get_config().PAGINATE_COUNT
self.assertLess(page_size, 100, "Default page size not sufficient for data set")
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 100)
self.assertTrue(response.data['next'].endswith(f'?limit={paginate_count}&offset={paginate_count}'))
self.assertTrue(response.data['next'].endswith(f'?limit={page_size}&offset={page_size}'))
self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), paginate_count)
self.assertEqual(len(response.data['results']), page_size)
def test_custom_page_size(self):
response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)
+25 -9
View File
@@ -1,7 +1,11 @@
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase
from utilities.data import check_ranges_overlap, ranges_to_string, string_to_ranges
from utilities.data import (
check_ranges_overlap,
ranges_to_string,
ranges_to_string_list,
string_to_ranges,
)
class RangeFunctionsTestCase(TestCase):
@@ -47,32 +51,44 @@ class RangeFunctionsTestCase(TestCase):
])
)
def test_ranges_to_string_list(self):
self.assertEqual(
ranges_to_string_list([
NumericRange(10, 20), # 10-19
NumericRange(30, 40), # 30-39
NumericRange(50, 51), # 50-50
NumericRange(100, 200), # 100-199
]),
['10-19', '30-39', '50', '100-199']
)
def test_ranges_to_string(self):
self.assertEqual(
ranges_to_string([
NumericRange(10, 20), # 10-19
NumericRange(30, 40), # 30-39
NumericRange(50, 51), # 50-50
NumericRange(100, 200), # 100-199
]),
'10-19,30-39,100-199'
'10-19,30-39,50,100-199'
)
def test_string_to_ranges(self):
self.assertEqual(
string_to_ranges('10-19, 30-39, 100-199'),
[
NumericRange(10, 19, bounds='[]'), # 10-19
NumericRange(30, 39, bounds='[]'), # 30-39
NumericRange(100, 199, bounds='[]'), # 100-199
NumericRange(10, 20, bounds='[)'), # 10-20
NumericRange(30, 40, bounds='[)'), # 30-40
NumericRange(100, 200, bounds='[)'), # 100-200
]
)
self.assertEqual(
string_to_ranges('1-2, 5, 10-12'),
[
NumericRange(1, 2, bounds='[]'), # 1-2
NumericRange(5, 5, bounds='[]'), # 5-5
NumericRange(10, 12, bounds='[]'), # 10-12
NumericRange(1, 3, bounds='[)'), # 1-3
NumericRange(5, 6, bounds='[)'), # 5-6
NumericRange(10, 13, bounds='[)'), # 10-13
]
)
@@ -0,0 +1,48 @@
from unittest.mock import patch
from django.test import TestCase, override_settings
from utilities.templatetags.builtins.tags import static_with_params
class StaticWithParamsTest(TestCase):
"""
Test the static_with_params template tag functionality.
"""
def test_static_with_params_basic(self):
"""Test basic parameter appending to static URL."""
result = static_with_params('test.js', v='1.0.0')
self.assertIn('test.js', result)
self.assertIn('v=1.0.0', result)
@override_settings(STATIC_URL='https://cdn.example.com/static/')
def test_static_with_params_existing_query_params(self):
"""Test appending parameters to URL that already has query parameters."""
# Mock the static() function to return a URL with existing query parameters
with patch('utilities.templatetags.builtins.tags.static') as mock_static:
mock_static.return_value = 'https://cdn.example.com/static/test.js?existing=param'
result = static_with_params('test.js', v='1.0.0')
# Should contain both existing and new parameters
self.assertIn('existing=param', result)
self.assertIn('v=1.0.0', result)
# Should not have double question marks
self.assertEqual(result.count('?'), 1)
@override_settings(STATIC_URL='https://cdn.example.com/static/')
def test_static_with_params_duplicate_parameter_warning(self):
"""Test that a warning is logged when parameters conflict."""
with patch('utilities.templatetags.builtins.tags.static') as mock_static:
mock_static.return_value = 'https://cdn.example.com/static/test.js?v=old_version'
with self.assertLogs('netbox.utilities.templatetags.tags', level='WARNING') as cm:
result = static_with_params('test.js', v='new_version')
# Check that warning was logged
self.assertIn("Parameter 'v' already exists", cm.output[0])
# Check that new parameter value is used
self.assertIn('v=new_version', result)
self.assertNotIn('v=old_version', result)