From 33ee6b23c7beead9f6436561d9f0de2163c0978a Mon Sep 17 00:00:00 2001 From: Giannis Terzopoulos Date: Mon, 2 Dec 2024 16:38:22 +0100 Subject: [PATCH] Fixed #35529 -- Added support for positional arguments in querystring template tag. --- AUTHORS | 1 + django/template/defaulttags.py | 60 ++++++++++++----- docs/ref/templates/builtins.txt | 34 ++++++---- docs/releases/5.2.txt | 3 + .../syntax_tests/test_querystring.py | 67 +++++++++++++++++++ 5 files changed, 135 insertions(+), 30 deletions(-) diff --git a/AUTHORS b/AUTHORS index c9a26fa6c8..ae5b309288 100644 --- a/AUTHORS +++ b/AUTHORS @@ -387,6 +387,7 @@ answer newbie questions, and generally made Django that much better: Georg "Hugo" Bauer Georgi Stanojevski Gerardo Orozco + Giannis Terzopoulos Gil Gonçalves Girish Kumar Girish Sontakke diff --git a/django/template/defaulttags.py b/django/template/defaulttags.py index ae74679ec6..8098875895 100644 --- a/django/template/defaulttags.py +++ b/django/template/defaulttags.py @@ -4,12 +4,13 @@ import re import sys import warnings from collections import namedtuple -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from datetime import datetime from itertools import cycle as itertools_cycle from itertools import groupby from django.conf import settings +from django.http import QueryDict from django.utils import timezone from django.utils.html import conditional_escape, escape, format_html from django.utils.lorem_ipsum import paragraphs, words @@ -1170,11 +1171,13 @@ def now(parser, token): @register.simple_tag(name="querystring", takes_context=True) -def querystring(context, query_dict=None, **kwargs): +def querystring(context, *args, **kwargs): """ - Add, remove, and change parameters of a ``QueryDict`` and return the result - as a query string. If the ``query_dict`` argument is not provided, default - to ``request.GET``. + Modify and return a query string with updated parameters. + + Add, remove, or change parameters in a given `QueryDict` or `dict`, + returning the result as a query string. Any arguments provided will + override existing keys. If no arguments are given, `request.GET` is used. For example:: @@ -1188,21 +1191,44 @@ def querystring(context, query_dict=None, **kwargs): {% querystring page=page_obj.next_page_number %} - A custom ``QueryDict`` can also be used:: + A custom `QueryDict` can be used:: {% querystring my_query_dict foo=3 %} + + Or a `dict` can be used:: + + {% querystring my_dict foo=3 %} + + As can a mix of multiple args and kwargs:: + + {% querystring my_dict my_query_dict foo=3 bar=None %} """ - if query_dict is None: - query_dict = context.request.GET - query_dict = query_dict.copy() - for key, value in kwargs.items(): - if value is None: - if key in query_dict: - del query_dict[key] - elif isinstance(value, Iterable) and not isinstance(value, str): - query_dict.setlist(key, value) - else: - query_dict[key] = value + try: + query_dict = context.request.GET.copy() + except AttributeError: + if not args: + raise + query_dict = QueryDict(mutable=True) + for d in args + (kwargs,): + if d is None: + continue + if not isinstance(d, Mapping): + raise TemplateSyntaxError( + "querystring requires mappings for positional arguments (received %r " + "instead)." % d + ) + for key, value in d.items(): + if not isinstance(key, str): + raise TemplateSyntaxError( + "querystring requires strings for mapping keys (received %r " + "instead)." % key + ) + if value is None: + query_dict.pop(key, None) + elif isinstance(value, Iterable) and not isinstance(value, str): + query_dict.setlist(key, value) + else: + query_dict[key] = value if not query_dict: return "" query_string = query_dict.urlencode() diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index 8673727861..19fc93dc1a 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -961,20 +961,20 @@ output (as a string) inside a variable. This is useful if you want to use Outputs a URL-encoded formatted query string based on the provided parameters. -This tag requires a :class:`~django.http.QueryDict` instance, which defaults to -:attr:`request.GET ` if none is provided. +This tag requires a :class:`~django.http.QueryDict` or :class:`dict` instance, +which defaults to :attr:`request.GET ` if none is +provided. -If the :class:`~django.http.QueryDict` is empty and no additional parameters -are provided, an empty string is returned. A non-empty result includes a -leading ``"?"``. +A non-empty result includes a leading ``"?"``, whereas an empty result returns +an empty string. .. admonition:: Using ``request.GET`` as default To use ``request.GET`` as the default ``QueryDict`` instance, the ``django.template.context_processors.request`` context processor should be enabled. If it's not enabled, you must either explicitly pass the - ``request`` object into the template context, or provide a ``QueryDict`` - instance to this tag. + ``request`` object into the template context, or pass the ``request.GET`` + ``QueryDict`` instance to this tag. Basic usage ~~~~~~~~~~~ @@ -993,16 +993,24 @@ Outputs the current query string verbatim. So if the query string is Outputs the current query string with the addition of the ``size`` parameter. Following the previous example, the output would be ``?color=green&size=M``. -Custom QueryDict -~~~~~~~~~~~~~~~~ +Customizing the default QueryDict +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: html+django - {% querystring my_query_dict %} + {% querystring my_query_dict my_dict %} -You can provide a custom ``QueryDict`` to be used instead of ``request.GET``. -So if ``my_query_dict`` is ````, this outputs -``?color=blue``. +You can provide custom ``QueryDict`` or ``dict`` instances as positional +arguments to be used instead of ``request.GET``. Key values from later +arguments take precedence over previously seen keys. So if ``my_query_dict`` +is ```` and ``my_dict`` is +``{'color': 'orange', 'fabric': 'silk'}``, this outputs +``?color=orange&size=S&fabric=silk``. + +.. versionchanged:: 5.2 + + Support for multiple positional arguments and positional arguments of + type ``dict`` were added. Setting items ~~~~~~~~~~~~~ diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index aaf47ff8e8..a65d3165f1 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -360,6 +360,9 @@ Templates * The new :meth:`~django.template.Library.simple_block_tag` decorator enables the creation of simple block tags, which can accept and use a section of the template. +* The :ttag:`querystring` template tag now supports multiple positional + arguments, which must be mappings, such as :class:`~django.http.QueryDict` + or ``dict``. Tests ~~~~~ diff --git a/tests/template_tests/syntax_tests/test_querystring.py b/tests/template_tests/syntax_tests/test_querystring.py index dea8ee0142..a09a6b5ca6 100644 --- a/tests/template_tests/syntax_tests/test_querystring.py +++ b/tests/template_tests/syntax_tests/test_querystring.py @@ -1,5 +1,6 @@ from django.http import QueryDict from django.template import RequestContext +from django.template.base import TemplateSyntaxError from django.test import RequestFactory, SimpleTestCase from ..utils import setup @@ -13,6 +14,10 @@ class QueryStringTagTests(SimpleTestCase): output = self.engine.render_to_string(template_name, context) self.assertEqual(output, expected) + def assertTemplateSyntaxError(self, template_name, context, expected): + with self.assertRaisesMessage(TemplateSyntaxError, expected): + self.engine.render_to_string(template_name, context) + @setup({"test_querystring_empty_get_params": "{% querystring %}"}) def test_querystring_empty_get_params(self): context = RequestContext(self.request_factory.get("/")) @@ -65,6 +70,14 @@ class QueryStringTagTests(SimpleTestCase): context = RequestContext(request) self.assertRenderEqual("querystring_remove", context, expected="?a=1") + @setup({"querystring_remove_dict": "{% querystring my_dict a=1 %}"}) + def test_querystring_remove_from_dict(self): + request = self.request_factory.get("/", {"test": "value"}) + template = self.engine.get_template("querystring_remove_dict") + context = RequestContext(request, {"my_dict": {"test": None}}) + output = template.render(context) + self.assertEqual(output, "?a=1") + @setup({"querystring_remove_nonexistent": "{% querystring nonexistent=None a=1 %}"}) def test_querystring_remove_nonexistent(self): request = self.request_factory.get("/", {"x": "y", "a": "1"}) @@ -73,6 +86,60 @@ class QueryStringTagTests(SimpleTestCase): "querystring_remove_nonexistent", context, expected="?x=y&a=1" ) + @setup({"querystring_same_arg": "{% querystring a=1 a=2 %}"}) + def test_querystring_same_arg(self): + msg = "'querystring' received multiple values for keyword argument 'a'" + self.assertTemplateSyntaxError("querystring_same_arg", {}, msg) + + @setup({"querystring_variable": "{% querystring a=a %}"}) + def test_querystring_variable(self): + request = self.request_factory.get("/") + template = self.engine.get_template("querystring_variable") + context = RequestContext(request, {"a": 1}) + output = template.render(context) + self.assertEqual(output, "?a=1") + + @setup({"querystring_dict": "{% querystring my_dict %}"}) + def test_querystring_dict(self): + context = {"my_dict": {"a": 1}} + output = self.engine.render_to_string("querystring_dict", context) + self.assertEqual(output, "?a=1") + + @setup({"querystring_dict_list": "{% querystring my_dict %}"}) + def test_querystring_dict_list_values(self): + context = {"my_dict": {"a": [1, 2]}} + output = self.engine.render_to_string("querystring_dict_list", context) + self.assertEqual(output, "?a=1&a=2") + + @setup({"querystring_non_string_dict_keys": "{% querystring my_dict %}"}) + def test_querystring_non_string_dict_keys(self): + context = {"my_dict": {0: 1}} + msg = "querystring requires strings for mapping keys (received 0 instead)." + self.assertTemplateSyntaxError("querystring_non_string_dict_keys", context, msg) + + @setup({"querystring_non_dict_args": "{% querystring somevar %}"}) + def test_querystring_non_dict_args(self): + context = {"somevar": 0} + msg = ( + "querystring requires mappings for positional arguments (received 0 " + "instead)." + ) + self.assertTemplateSyntaxError("querystring_non_dict_args", context, msg) + + @setup( + { + "querystring_multiple_args_override": ( + "{% querystring my_dict my_query_dict x=3 %}" + ) + } + ) + def test_querystring_multiple_args_override(self): + context = {"my_dict": {"x": 0}, "my_query_dict": QueryDict("a=1&b=2")} + output = self.engine.render_to_string( + "querystring_multiple_args_override", context + ) + self.assertEqual(output, "?x=3&a=1&b=2") + @setup({"querystring_list": "{% querystring a=my_list %}"}) def test_querystring_add_list(self): request = self.request_factory.get("/")