diff --git a/django/template/defaulttags.py b/django/template/defaulttags.py index 79c10232bbc..188bdf8c05a 100644 --- a/django/template/defaulttags.py +++ b/django/template/defaulttags.py @@ -10,6 +10,7 @@ from itertools import groupby from django.conf import settings from django.utils import timezone from django.utils.html import conditional_escape, escape, format_html +from django.utils.itercompat import is_iterable from django.utils.lorem_ipsum import paragraphs, words from django.utils.safestring import mark_safe @@ -1167,6 +1168,46 @@ def now(parser, token): return NowNode(format_string, asvar) +@register.simple_tag(takes_context=True) +def query_string(context, query_dict=None, **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``. + + For example:: + + {% query_string foo=3 %} + + To remove a key:: + + {% query_string foo=None %} + + To use with pagination:: + + {% query_string page=page_obj.next_page_number %} + + A custom ``QueryDict`` can also be used:: + + {% query_string my_query_dict foo=3 %} + """ + 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 is_iterable(value) 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() + return f"?{query_string}" + + @register.tag def regroup(parser, token): """ diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index 038a2093c45..6af68aebcae 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -953,6 +953,78 @@ output (as a string) inside a variable. This is useful if you want to use {% now "Y" as current_year %} {% blocktranslate %}Copyright {{ current_year }}{% endblocktranslate %} +.. templatetag:: query_string + +``query_string`` +---------------- + +.. versionadded:: 5.1 + +Outputs the query string from a given :class:`~django.http.QueryDict` instance, +if provided, or ``request.GET`` if not and the +``django.template.context_processors.request`` context processor is enabled. +If the ``QueryDict`` is empty, then the output will be an empty string. +Otherwise, the query string will be returned with a leading ``"?"``. + +If not using the ``django.template.context_processors.request`` context +processor, you must pass either the ``request`` into the template context or a +``QueryDict`` instance into this tag. + +The following example outputs the current query string verbatim. So if the +query string is ``?color=green&size=M``, the output would be +``?color=green&size=M``: + +.. code-block:: html+django + + {% query_string %} + +You can also pass in a custom ``QueryDict`` that will be used instead of +``request.GET``: + +.. code-block:: html+django + + {% query_string my_query_dict %} + +Each keyword argument will be added to the query string, replacing any existing +value for that key. With the query string ``?color=blue``, the following would +result in ``?color=red&size=S``: + +.. code-block:: html+django + + {% query_string color="red" size="S" %} + +It is possible to remove parameters by passing ``None`` as a value. With the +query string ``?color=blue&size=M``, the following would result in ``?size=M``: + +.. code-block:: html+django + + {% query_string color=None %} + +If the given parameter is a list, the value will remain as a list. For example, +if ``my_list`` is set to ``["red", "blue"]``, the following would result in +``?color=red&color=blue``: + +.. code-block:: html+django + + {% query_string color=my_list %} + +A common example of using this tag is to preserve the current query string when +displaying a page of results, while adding a link to the next and previous +pages of results. For example, if the paginator is currently on page 3, and +the current query string is ``?color=blue&size=M&page=3``, the following code +would output ``?color=blue&size=M&page=4``: + +.. code-block:: html+django + + {% query_string page=page.next_page_number %} + +You can also store the value in a variable, for example, if you need multiple +links to the same page with syntax such as: + +.. code-block:: html+django + + {% query_string page=page.next_page_number as next_page %} + .. templatetag:: regroup ``regroup`` diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt index 799a30811fa..e352b9c04fb 100644 --- a/docs/releases/5.1.txt +++ b/docs/releases/5.1.txt @@ -198,6 +198,11 @@ Templates be made available on the ``Template`` instance. Such data may be used, for example, by the template loader, or other template clients. +* The new :ttag:`{% query_string %} ` template tag allows + changing a :class:`~django.http.QueryDict` instance for use in links, for + example, to generate a link to the next page while keeping any filtering + options in place. + Tests ~~~~~ diff --git a/tests/template_tests/syntax_tests/test_query_string.py b/tests/template_tests/syntax_tests/test_query_string.py new file mode 100644 index 00000000000..13c0dc1d08c --- /dev/null +++ b/tests/template_tests/syntax_tests/test_query_string.py @@ -0,0 +1,100 @@ +from django.http import QueryDict +from django.template import RequestContext +from django.test import RequestFactory, SimpleTestCase + +from ..utils import setup + + +class QueryStringTagTests(SimpleTestCase): + def setUp(self): + self.request_factory = RequestFactory() + + @setup({"query_string_empty": "{% query_string %}"}) + def test_query_string_empty(self): + request = self.request_factory.get("/") + template = self.engine.get_template("query_string_empty") + context = RequestContext(request) + output = template.render(context) + self.assertEqual(output, "") + + @setup({"query_string_non_empty": "{% query_string %}"}) + def test_query_string_non_empty(self): + request = self.request_factory.get("/", {"a": "b"}) + template = self.engine.get_template("query_string_non_empty") + context = RequestContext(request) + output = template.render(context) + self.assertEqual(output, "?a=b") + + @setup({"query_string_multiple": "{% query_string %}"}) + def test_query_string_multiple(self): + request = self.request_factory.get("/", {"x": "y", "a": "b"}) + template = self.engine.get_template("query_string_multiple") + context = RequestContext(request) + output = template.render(context) + self.assertEqual(output, "?x=y&a=b") + + @setup({"query_string_replace": "{% query_string a=1 %}"}) + def test_query_string_replace(self): + request = self.request_factory.get("/", {"x": "y", "a": "b"}) + template = self.engine.get_template("query_string_replace") + context = RequestContext(request) + output = template.render(context) + self.assertEqual(output, "?x=y&a=1") + + @setup({"query_string_add": "{% query_string test_new='something' %}"}) + def test_query_string_add(self): + request = self.request_factory.get("/", {"a": "b"}) + template = self.engine.get_template("query_string_add") + context = RequestContext(request) + output = template.render(context) + self.assertEqual(output, "?a=b&test_new=something") + + @setup({"query_string_remove": "{% query_string test=None a=1 %}"}) + def test_query_string_remove(self): + request = self.request_factory.get("/", {"test": "value", "a": "1"}) + template = self.engine.get_template("query_string_remove") + context = RequestContext(request) + output = template.render(context) + self.assertEqual(output, "?a=1") + + @setup( + {"query_string_remove_nonexistent": "{% query_string nonexistent=None a=1 %}"} + ) + def test_query_string_remove_nonexistent(self): + request = self.request_factory.get("/", {"x": "y", "a": "1"}) + template = self.engine.get_template("query_string_remove_nonexistent") + context = RequestContext(request) + output = template.render(context) + self.assertEqual(output, "?x=y&a=1") + + @setup({"query_string_list": "{% query_string a=my_list %}"}) + def test_query_string_add_list(self): + request = self.request_factory.get("/") + template = self.engine.get_template("query_string_list") + context = RequestContext(request, {"my_list": [2, 3]}) + output = template.render(context) + self.assertEqual(output, "?a=2&a=3") + + @setup({"query_string_query_dict": "{% query_string request.GET a=2 %}"}) + def test_query_string_with_explicit_query_dict(self): + request = self.request_factory.get("/", {"a": 1}) + output = self.engine.render_to_string( + "query_string_query_dict", {"request": request} + ) + self.assertEqual(output, "?a=2") + + @setup( + {"query_string_query_dict_no_request": "{% query_string my_query_dict a=2 %}"} + ) + def test_query_string_with_explicit_query_dict_and_no_request(self): + context = {"my_query_dict": QueryDict("a=1&b=2")} + output = self.engine.render_to_string( + "query_string_query_dict_no_request", context + ) + self.assertEqual(output, "?a=2&b=2") + + @setup({"query_string_no_request_no_query_dict": "{% query_string %}"}) + def test_query_string_without_request_or_explicit_query_dict(self): + msg = "'Context' object has no attribute 'request'" + with self.assertRaisesMessage(AttributeError, msg): + self.engine.render_to_string("query_string_no_request_no_query_dict")