Repository: wagtail/wagtail

Issue:
Decorating RoutablePageMixin routes with "route_path" instead of "route" (path vs re_path)
### Is your proposal related to a problem?

In migrating a Django application to a Wagtail app, I've been using RoutablePageMixin in some cases and wanting to use the newer Django django.urls.path patterns instead of the re_path to make copying the url patterns from the original application's urls.py a bit easier.

I've mentally switched away from the regular expressions for url matching and am struggling going back to using the regex's.

### Describe the solution you'd like

I'd like to decorate the route with something like `@route_path` or have `route()` in `routable_page/models.py` have a check to see if the pattern is a regular expression or not and (RegexPattern or RoutePattern). 

### Describe alternatives you've considered
For now I've created a custom `route_path` function in `/custom/routable_pages/models.py` that appends a `path` to _routablepage_routes instead of `re_path` (as in stock). It looks like:

/custom/routable_page/models.py
```python
from django.urls import path, re_path

_custom_creation_counter = 0

def route_path(pattern, name=None):
    """
    Adds a path more like the Django 2.0+ Url Dispatcher rules
    """
    def decorator(view_func):
        global _custom_creation_counter
        _custom_creation_counter += 1
        # Make sure page has _routablepage_routes attribute
        if not hasattr(view_func, '_routablepage_routes'):
            view_func._routablepage_routes = []

            view_func._routablepage_routes.append((
                path(pattern, view_func, name=(name or view_func.__name__)),
                _custom_creation_counter,
            ))
        return view_func
    return decorator

    # a copy of the stock route follows right below, included in this location for my own sanity in the global scope
``` 

and decorate the routable functions with `@route_path` which seems to be working so far, but just getting into it and wondering if I'm going to get burned doing things this way.

/subscribers/models.py
```python
from myprojectname.custom.routable_page.models import route_path

class SubscribePage(RoutablePageMixin, Page):

    ....

    @route_path("subscribe/")
    def subscribe(self, request, *args, **kwargs):
        """
        Confirm the form is submitted correctly, and conditionally add
        the subscriber to the list
        """
        from subscribers.forms import SubscribeForm
        if request.method == 'POST':
            form = SubscribeForm(request.POST)
            if form.is_valid():
                form_submission = form.save(commit=False)
                ....
```


### Additional context
Obviously, the 'try it and see' approach applies here, but wondering if there is a deeper design decision in Wagtail which would suggest to not take this direction.


Discussion:

Resolving patch:
diff --git a/wagtail/contrib/routable_page/models.py b/wagtail/contrib/routable_page/models.py
--- a/wagtail/contrib/routable_page/models.py
+++ b/wagtail/contrib/routable_page/models.py
@@ -1,15 +1,23 @@
+import logging
+from functools import partial
+
+from django.core.checks import Warning
 from django.http import Http404
 from django.template.response import TemplateResponse
-from django.urls import URLResolver, re_path
-from django.urls.resolvers import RegexPattern
+from django.urls import URLResolver
+from django.urls import path as path_func
+from django.urls import re_path as re_path_func
+from django.urls.resolvers import RegexPattern, RoutePattern
 
 from wagtail.models import Page
 from wagtail.url_routing import RouteResult
 
 _creation_counter = 0
 
+logger = logging.getLogger("wagtail.routablepage")
+
 
-def route(pattern, name=None):
+def _path(pattern, name=None, func=None):
     def decorator(view_func):
         global _creation_counter
         _creation_counter += 1
@@ -21,7 +29,7 @@ def decorator(view_func):
         # Add new route to view
         view_func._routablepage_routes.append(
             (
-                re_path(pattern, view_func, name=(name or view_func.__name__)),
+                func(pattern, view_func, name=(name or view_func.__name__)),
                 _creation_counter,
             )
         )
@@ -31,13 +39,20 @@ def decorator(view_func):
     return decorator
 
 
+re_path = partial(_path, func=re_path_func)
+path = partial(_path, func=path_func)
+
+# Make route an alias of re_path for backwards compatibility.
+route = re_path
+
+
 class RoutablePageMixin:
     """
     This class can be mixed in to a Page model, allowing extra routes to be
     added to it.
     """
 
-    @route(r"^$")
+    @path("")
     def index_route(self, request, *args, **kwargs):
         request.is_preview = getattr(request, "is_preview", False)
 
@@ -78,6 +93,37 @@ def get_resolver(cls):
 
         return cls._routablepage_urlresolver
 
+    @classmethod
+    def check(cls, **kwargs):
+        errors = super().check(**kwargs)
+        errors.extend(cls._check_path_with_regex())
+        return errors
+
+    @classmethod
+    def _check_path_with_regex(cls):
+        routes = cls.get_subpage_urls()
+        errors = []
+        for route in routes:
+            if isinstance(route.pattern, RoutePattern):
+                pattern = route.pattern._route
+                if (
+                    "(?P<" in pattern
+                    or pattern.startswith("^")
+                    or pattern.endswith("$")
+                ):
+                    errors.append(
+                        Warning(
+                            (
+                                f"Your URL pattern {route.name or route.callback.__name__} has a "
+                                "route that contains '(?P<', begins with a '^', or ends with a '$'."
+                            ),
+                            hint="Decorate your view with re_path if you want to use regexp.",
+                            obj=cls,
+                            id="wagtailroutablepage.W001",
+                        )
+                    )
+        return errors
+
     def reverse_subpage(self, name, args=None, kwargs=None):
         """
         This method takes a route name/arguments and returns a URL path.
@@ -136,7 +182,7 @@ def render(self, request, *args, template=None, context_overrides=None, **kwargs
 
         .. code-block:: python
 
-            @route(r'^$') # override the default route
+            @path('') # override the default route
             def upcoming_events(self, request):
                 return self.render(request, context_overrides={
                     'title': "Current events",
@@ -148,7 +194,7 @@ def upcoming_events(self, request):
 
         .. code-block:: python
 
-            @route(r'^past/$')
+            @path('past/')
             def past_events(self, request):
                 return self.render(
                     request,


Test patch:
diff --git a/wagtail/contrib/routable_page/tests.py b/wagtail/contrib/routable_page/tests.py
--- a/wagtail/contrib/routable_page/tests.py
+++ b/wagtail/contrib/routable_page/tests.py
@@ -1,7 +1,9 @@
 from unittest import mock
 
+from django.core import checks
 from django.test import RequestFactory, TestCase
 from django.test.utils import override_settings
+from django.urls import path
 from django.urls.exceptions import NoReverseMatch
 
 from wagtail.contrib.routable_page.templatetags.wagtailroutablepage_tags import (
@@ -49,6 +51,24 @@ def test_resolve_archive_by_author_view(self):
         self.assertEqual(args, ())
         self.assertEqual(kwargs, {"author_slug": "joe-bloggs"})
 
+    def test_resolve_archive_by_title_view(self):
+        view, args, kwargs = self.routable_page.resolve_subpage(
+            "/archive/title/some-title/"
+        )
+
+        self.assertEqual(view, self.routable_page.archive_by_title)
+        self.assertEqual(args, ())
+        self.assertEqual(kwargs, {"title": "some-title"})
+
+    def test_resolve_archive_by_category_view(self):
+        view, args, kwargs = self.routable_page.resolve_subpage(
+            "/archive/category/some-category/"
+        )
+
+        self.assertEqual(view, self.routable_page.archive_by_category)
+        self.assertEqual(args, ())
+        self.assertEqual(kwargs, {"category_slug": "some-category"})
+
     def test_resolve_external_view(self):
         view, args, kwargs = self.routable_page.resolve_subpage("/external/joe-bloggs/")
 
@@ -80,6 +100,13 @@ def test_reverse_archive_by_author_view(self):
 
         self.assertEqual(url, "archive/author/joe-bloggs/")
 
+    def test_reverse_archive_by_title_view(self):
+        url = self.routable_page.reverse_subpage(
+            "archive_by_title", kwargs={"title": "some-title"}
+        )
+
+        self.assertEqual(url, "archive/title/some-title/")
+
     def test_reverse_overridden_name(self):
         url = self.routable_page.reverse_subpage("name_overridden")
 
@@ -157,6 +184,18 @@ def test_get_archive_by_author_view(self):
 
         self.assertContains(response, "ARCHIVE BY AUTHOR: joe-bloggs")
 
+    def test_get_archive_by_title_view(self):
+        response = self.client.get(self.routable_page.url + "archive/title/some-title/")
+
+        self.assertContains(response, "ARCHIVE BY TITLE: some-title")
+
+    def test_get_archive_by_category_view(self):
+        response = self.client.get(
+            self.routable_page.url + "archive/category/some-category/"
+        )
+
+        self.assertContains(response, "ARCHIVE BY CATEGORY: some-category")
+
     def test_get_external_view(self):
         response = self.client.get(self.routable_page.url + "external/joe-bloggs/")
 
@@ -186,6 +225,20 @@ def __set__(self, instance, value):
         finally:
             del RoutablePageTest.descriptor
 
+    def test_warning_path_with_regex(self):
+        route = path(r"^foo/$", lambda request: None, name="path_with_regex")
+
+        warning = checks.Warning(
+            "Your URL pattern path_with_regex has a route that contains '(?P<', begins with a '^', or ends with a '$'.",
+            hint="Decorate your view with re_path if you want to use regexp.",
+            obj=RoutablePageTest,
+            id="wagtailroutablepage.W001",
+        )
+        with mock.patch.object(
+            RoutablePageTest, "get_subpage_urls", return_value=[route]
+        ):
+            self.assertEqual(RoutablePageTest.check(), [warning])
+
 
 class TestRoutablePageTemplateTag(TestCase):
     def setUp(self):
@@ -224,6 +277,25 @@ def test_templatetag_reverse_archive_by_author_view(self):
             url, "/%s/archive/author/joe-bloggs/" % self.routable_page.slug
         )
 
+    def test_templatetag_reverse_archive_by_title_view(self):
+        url = routablepageurl(
+            self.context, self.routable_page, "archive_by_title", title="some-title"
+        )
+
+        self.assertEqual(url, "/%s/archive/title/some-title/" % self.routable_page.slug)
+
+    def test_templatetag_reverse_archive_by_category_view(self):
+        url = routablepageurl(
+            self.context,
+            self.routable_page,
+            "archive_by_category",
+            category_slug="some-category",
+        )
+
+        self.assertEqual(
+            url, "/%s/archive/category/some-category/" % self.routable_page.slug
+        )
+
     def test_templatetag_reverse_external_view(self):
         url = routablepageurl(
             self.context, self.routable_page, "external_view", "joe-bloggs"
@@ -293,6 +365,25 @@ def test_templatetag_reverse_archive_by_author_view(self):
             url, "/%s/archive/author/joe-bloggs/" % self.routable_page.slug
         )
 
+    def test_templatetag_reverse_archive_by_title_view(self):
+        url = routablepageurl(
+            self.context, self.routable_page, "archive_by_title", title="some-title"
+        )
+
+        self.assertEqual(url, "/%s/archive/title/some-title/" % self.routable_page.slug)
+
+    def test_templatetag_reverse_archive_by_category_view(self):
+        url = routablepageurl(
+            self.context,
+            self.routable_page,
+            "archive_by_category",
+            category_slug="some-category",
+        )
+
+        self.assertEqual(
+            url, "/%s/archive/category/some-category/" % self.routable_page.slug
+        )
+
     def test_templatetag_reverse_external_view(self):
         url = routablepageurl(
             self.context, self.routable_page, "external_view", "joe-bloggs"
@@ -367,6 +458,30 @@ def test_templatetag_reverse_archive_by_author_view(self):
             "http://localhost/%s/archive/author/joe-bloggs/" % self.routable_page.slug,
         )
 
+    def test_templatetag_reverse_archive_by_title_view(self):
+        url = routablepageurl(
+            self.context, self.routable_page, "archive_by_title", title="some-title"
+        )
+
+        self.assertEqual(
+            url,
+            "http://localhost/%s/archive/title/some-title/" % self.routable_page.slug,
+        )
+
+    def test_templatetag_reverse_archive_by_category_view(self):
+        url = routablepageurl(
+            self.context,
+            self.routable_page,
+            "archive_by_category",
+            category_slug="some-category",
+        )
+
+        self.assertEqual(
+            url,
+            "http://localhost/%s/archive/category/some-category/"
+            % self.routable_page.slug,
+        )
+
     def test_templatetag_reverse_external_view(self):
         url = routablepageurl(
             self.context, self.routable_page, "external_view", "joe-bloggs"
diff --git a/wagtail/test/routablepage/models.py b/wagtail/test/routablepage/models.py
--- a/wagtail/test/routablepage/models.py
+++ b/wagtail/test/routablepage/models.py
@@ -1,6 +1,6 @@
 from django.http import HttpResponse
 
-from wagtail.contrib.routable_page.models import RoutablePage, route
+from wagtail.contrib.routable_page.models import RoutablePage, path, re_path, route
 
 
 def routable_page_external_view(request, arg="ARG NOT SET"):
@@ -21,6 +21,14 @@ def archive_by_year(self, request, year):
     def archive_by_author(self, request, author_slug):
         return HttpResponse("ARCHIVE BY AUTHOR: " + author_slug)
 
+    @path("archive/title/<slug:title>/")
+    def archive_by_title(self, request, title):
+        return HttpResponse("ARCHIVE BY TITLE: " + title)
+
+    @re_path(r"^archive/category/(?P<category_slug>.+)/$")
+    def archive_by_category(self, request, category_slug):
+        return HttpResponse("ARCHIVE BY CATEGORY: " + category_slug)
+
     @route(r"^external/(.+)/$")
     @route(r"^external-no-arg/$")
     def external_view(self, *args, **kwargs):