Browse Source

Fixed #25582 -- Added support for query and fragment to django.urls.reverse().

Ben Cardy 3 months ago
parent
commit
f30b527f17
5 changed files with 136 additions and 6 deletions
  1. 1 0
      AUTHORS
  2. 23 3
      django/urls/base.py
  3. 28 1
      docs/ref/urlresolvers.txt
  4. 3 1
      docs/releases/5.2.txt
  5. 81 1
      tests/urlpatterns_reverse/tests.py

+ 1 - 0
AUTHORS

@@ -146,6 +146,7 @@ answer newbie questions, and generally made Django that much better:
     Batman
     Batman
     Batuhan Taskaya <batuhanosmantaskaya@gmail.com>
     Batuhan Taskaya <batuhanosmantaskaya@gmail.com>
     Baurzhan Ismagulov <ibr@radix50.net>
     Baurzhan Ismagulov <ibr@radix50.net>
+    Ben Cardy <me@bencardy.co.uk>
     Ben Dean Kawamura <ben.dean.kawamura@gmail.com>
     Ben Dean Kawamura <ben.dean.kawamura@gmail.com>
     Ben Firshman <ben@firshman.co.uk>
     Ben Firshman <ben@firshman.co.uk>
     Ben Godfrey <http://aftnn.org>
     Ben Godfrey <http://aftnn.org>

+ 23 - 3
django/urls/base.py

@@ -1,7 +1,8 @@
-from urllib.parse import unquote, urlsplit, urlunsplit
+from urllib.parse import unquote, urlencode, urlsplit, urlunsplit
 
 
 from asgiref.local import Local
 from asgiref.local import Local
 
 
+from django.http import QueryDict
 from django.utils.functional import lazy
 from django.utils.functional import lazy
 from django.utils.translation import override
 from django.utils.translation import override
 
 
@@ -24,7 +25,16 @@ def resolve(path, urlconf=None):
     return get_resolver(urlconf).resolve(path)
     return get_resolver(urlconf).resolve(path)
 
 
 
 
-def reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None):
+def reverse(
+    viewname,
+    urlconf=None,
+    args=None,
+    kwargs=None,
+    current_app=None,
+    *,
+    query=None,
+    fragment=None,
+):
     if urlconf is None:
     if urlconf is None:
         urlconf = get_urlconf()
         urlconf = get_urlconf()
     resolver = get_resolver(urlconf)
     resolver = get_resolver(urlconf)
@@ -85,7 +95,17 @@ def reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None):
                 ns_pattern, resolver, tuple(ns_converters.items())
                 ns_pattern, resolver, tuple(ns_converters.items())
             )
             )
 
 
-    return resolver._reverse_with_prefix(view, prefix, *args, **kwargs)
+    resolved_url = resolver._reverse_with_prefix(view, prefix, *args, **kwargs)
+    if query is not None:
+        if isinstance(query, QueryDict):
+            query_string = query.urlencode()
+        else:
+            query_string = urlencode(query, doseq=True)
+        if query_string:
+            resolved_url += "?" + query_string
+    if fragment is not None:
+        resolved_url += "#" + fragment
+    return resolved_url
 
 
 
 
 reverse_lazy = lazy(reverse, str)
 reverse_lazy = lazy(reverse, str)

+ 28 - 1
docs/ref/urlresolvers.txt

@@ -10,7 +10,7 @@
 The ``reverse()`` function can be used to return an absolute path reference
 The ``reverse()`` function can be used to return an absolute path reference
 for a given view and optional parameters, similar to the :ttag:`url` tag:
 for a given view and optional parameters, similar to the :ttag:`url` tag:
 
 
-.. function:: reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None)
+.. function:: reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None, *, query=None, fragment=None)
 
 
 ``viewname`` can be a :ref:`URL pattern name <naming-url-patterns>` or the
 ``viewname`` can be a :ref:`URL pattern name <naming-url-patterns>` or the
 callable view object used in the URLconf. For example, given the following
 callable view object used in the URLconf. For example, given the following
@@ -67,6 +67,33 @@ namespaces into URLs on specific application instances, according to the
 The ``urlconf`` argument is the URLconf module containing the URL patterns to
 The ``urlconf`` argument is the URLconf module containing the URL patterns to
 use for reversing. By default, the root URLconf for the current thread is used.
 use for reversing. By default, the root URLconf for the current thread is used.
 
 
+The ``query`` keyword argument specifies parameters to be added to the returned
+URL. It can accept an instance of :class:`~django.http.QueryDict` (such as
+``request.GET``) or any value compatible with :func:`urllib.parse.urlencode`.
+The encoded query string is appended to the resolved URL, prefixed by a ``?``.
+
+The ``fragment`` keyword argument specifies a fragment identifier to be
+appended to the returned URL (that is, after the path and query string,
+preceded by a ``#``).
+
+For example:
+
+.. code-block:: pycon
+
+   >>> from django.urls import reverse
+   >>> reverse("admin:index", query={"q": "biscuits", "page": 2}, fragment="results")
+   '/admin/?q=biscuits&page=2#results'
+   >>> reverse("admin:index", query=[("color", "blue"), ("color", 1), ("none", None)])
+   '/admin/?color=blue&color=1&none=None'
+   >>> reverse("admin:index", query={"has empty spaces": "also has empty spaces!"})
+   '/admin/?has+empty+spaces=also+has+empty+spaces%21'
+   >>> reverse("admin:index", fragment="no encoding is done")
+   '/admin/#no encoding is done'
+
+.. versionchanged:: 5.2
+
+    The ``query`` and ``fragment`` arguments were added.
+
 .. note::
 .. note::
 
 
     The string returned by ``reverse()`` is already
     The string returned by ``reverse()`` is already

+ 3 - 1
docs/releases/5.2.txt

@@ -370,7 +370,9 @@ Tests
 URLs
 URLs
 ~~~~
 ~~~~
 
 
-* ...
+* :func:`~django.urls.reverse` now accepts ``query`` and ``fragment`` keyword
+  arguments, allowing the addition of a query string and/or fragment identifier
+  in the generated URL, respectively.
 
 
 Utilities
 Utilities
 ~~~~~~~~~
 ~~~~~~~~~

+ 81 - 1
tests/urlpatterns_reverse/tests.py

@@ -11,7 +11,12 @@ from admin_scripts.tests import AdminScriptTestCase
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist
 from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist
-from django.http import HttpRequest, HttpResponsePermanentRedirect, HttpResponseRedirect
+from django.http import (
+    HttpRequest,
+    HttpResponsePermanentRedirect,
+    HttpResponseRedirect,
+    QueryDict,
+)
 from django.shortcuts import redirect
 from django.shortcuts import redirect
 from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
 from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
 from django.test.utils import override_script_prefix
 from django.test.utils import override_script_prefix
@@ -531,6 +536,81 @@ class URLPatternReverse(SimpleTestCase):
         with self.assertRaises(NoReverseMatch):
         with self.assertRaises(NoReverseMatch):
             reverse(views.view_func_from_cbv)
             reverse(views.view_func_from_cbv)
 
 
+    def test_reverse_with_query(self):
+        self.assertEqual(
+            reverse("test", query={"hello": "world", "foo": 123}),
+            "/test/1?hello=world&foo=123",
+        )
+
+    def test_reverse_with_query_sequences(self):
+        cases = [
+            [("hello", "world"), ("foo", 123), ("foo", 456)],
+            (("hello", "world"), ("foo", 123), ("foo", 456)),
+            {"hello": "world", "foo": (123, 456)},
+        ]
+        for query in cases:
+            with self.subTest(query=query):
+                self.assertEqual(
+                    reverse("test", query=query), "/test/1?hello=world&foo=123&foo=456"
+                )
+
+    def test_reverse_with_fragment(self):
+        self.assertEqual(reverse("test", fragment="tab-1"), "/test/1#tab-1")
+
+    def test_reverse_with_fragment_not_encoded(self):
+        self.assertEqual(
+            reverse("test", fragment="tab 1 is the best!"), "/test/1#tab 1 is the best!"
+        )
+
+    def test_reverse_with_query_and_fragment(self):
+        self.assertEqual(
+            reverse("test", query={"hello": "world", "foo": 123}, fragment="tab-1"),
+            "/test/1?hello=world&foo=123#tab-1",
+        )
+
+    def test_reverse_with_empty_fragment(self):
+        self.assertEqual(reverse("test", fragment=None), "/test/1")
+        self.assertEqual(reverse("test", fragment=""), "/test/1#")
+
+    def test_reverse_with_invalid_fragment(self):
+        cases = [0, False, {}, [], set(), ()]
+        for fragment in cases:
+            with self.subTest(fragment=fragment):
+                with self.assertRaises(TypeError):
+                    reverse("test", fragment=fragment)
+
+    def test_reverse_with_empty_query(self):
+        cases = [None, "", {}, [], set(), (), QueryDict()]
+        for query in cases:
+            with self.subTest(query=query):
+                self.assertEqual(reverse("test", query=query), "/test/1")
+
+    def test_reverse_with_invalid_query(self):
+        cases = [0, False, [1, 3, 5], {1, 2, 3}]
+        for query in cases:
+            with self.subTest(query=query):
+                with self.assertRaises(TypeError):
+                    print(reverse("test", query=query))
+
+    def test_reverse_encodes_query_string(self):
+        self.assertEqual(
+            reverse(
+                "test",
+                query={
+                    "hello world": "django project",
+                    "foo": [123, 456],
+                    "@invalid": ["?", "!", "a b"],
+                },
+            ),
+            "/test/1?hello+world=django+project&foo=123&foo=456"
+            "&%40invalid=%3F&%40invalid=%21&%40invalid=a+b",
+        )
+
+    def test_reverse_with_query_from_querydict(self):
+        query_string = "a=1&b=2&b=3&c=4"
+        query_dict = QueryDict(query_string)
+        self.assertEqual(reverse("test", query=query_dict), f"/test/1?{query_string}")
+
 
 
 class ResolverTests(SimpleTestCase):
 class ResolverTests(SimpleTestCase):
     def test_resolver_repr(self):
     def test_resolver_repr(self):