diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 443562027ed..22d7ddcfb02 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -26,7 +26,7 @@ {# Initialize color mode #} {% django_htmx_script %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index d1dd1a55a46..28ca4b332dc 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -1,5 +1,9 @@ +import logging + from django import template +from django.templatetags.static import static from django.utils.safestring import mark_safe +from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from extras.choices import CustomFieldTypeChoices from utilities.querydict import dict_to_querydict @@ -11,6 +15,7 @@ 'customfield_value', 'htmx_table', 'formaction', + 'static_with_params', 'tag', ) @@ -127,3 +132,53 @@ def formaction(context): if context.get('htmx_navigation', False): return mark_safe('hx-push-url="true" hx-post') 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) diff --git a/netbox/utilities/tests/test_templatetags.py b/netbox/utilities/tests/test_templatetags.py new file mode 100644 index 00000000000..876eed215d5 --- /dev/null +++ b/netbox/utilities/tests/test_templatetags.py @@ -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)