mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-06 07:16:25 -06:00
Merge branch 'main' into feature
This commit is contained in:
+55
-16
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user