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 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 _path(pattern, name=None, func=None): def decorator(view_func): global _creation_counter _creation_counter += 1 # Make sure page has _routablepage_routes attribute if not hasattr(view_func, "_routablepage_routes"): view_func._routablepage_routes = [] # Add new route to view view_func._routablepage_routes.append( ( func(pattern, view_func, name=(name or view_func.__name__)), _creation_counter, ) ) return 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. """ @path("") def index_route(self, request, *args, **kwargs): request.is_preview = getattr(request, "is_preview", False) return TemplateResponse( request, self.get_template(request, *args, **kwargs), self.get_context(request, *args, **kwargs), ) @classmethod def get_subpage_urls(cls): routes = [] # Loop over this class's defined routes, in method resolution order. # Routes defined in the immediate class take precedence, followed by # immediate superclass and so on for klass in cls.__mro__: routes_for_class = [] for val in klass.__dict__.values(): if hasattr(val, "_routablepage_routes"): routes_for_class.extend(val._routablepage_routes) # sort routes by _creation_counter so that ones earlier in the class definition # take precedence routes_for_class.sort(key=lambda route: route[1]) routes.extend(route[0] for route in routes_for_class) return tuple(routes) @classmethod def get_resolver(cls): if "_routablepage_urlresolver" not in cls.__dict__: subpage_urls = cls.get_subpage_urls() cls._routablepage_urlresolver = URLResolver( RegexPattern(r"^/"), subpage_urls ) 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. """ args = args or [] kwargs = kwargs or {} return self.get_resolver().reverse(name, *args, **kwargs) def resolve_subpage(self, path): """ This method takes a URL path and finds the view to call. """ resolver_match = self.get_resolver().resolve(path) # Bind the method resolver_match.func = resolver_match.func.__get__(self, type(self)) return resolver_match def route(self, request, path_components): """ This hooks the subpage URLs into Wagtail's routing. """ if self.live: try: path = "/" if path_components: path += "/".join(path_components) + "/" resolver_match = self.resolve_subpage(path) request.routable_resolver_match = resolver_match view, args, kwargs = resolver_match return RouteResult(self, args=(view, args, kwargs)) except Http404: pass return super().route(request, path_components) def serve(self, request, view=None, args=None, kwargs=None): if args is None: args = [] if kwargs is None: kwargs = {} if view is None: return super().serve(request, *args, **kwargs) return view(request, *args, **kwargs) def render(self, request, *args, template=None, context_overrides=None, **kwargs): """ This method replicates what ``Page.serve()`` usually does when ``RoutablePageMixin`` is not used. By default, ``Page.get_template()`` is called to derive the template to use for rendering, and ``Page.get_context()`` is always called to gather the data to be included in the context. You can use the ``context_overrides`` keyword argument as a shortcut to override or add new values to the context. For example: .. code-block:: python @path('') # override the default route def upcoming_events(self, request): return self.render(request, context_overrides={ 'title': "Current events", 'events': EventPage.objects.live().future(), }) You can also use the ``template`` argument to specify an alternative template to use for rendering. For example: .. code-block:: python @path('past/') def past_events(self, request): return self.render( request, context_overrides={ 'title': "Past events", 'events': EventPage.objects.live().past(), }, template="events/event_index_historical.html", ) """ if template is None: template = self.get_template(request, *args, **kwargs) context = self.get_context(request, *args, **kwargs) context.update(context_overrides or {}) return TemplateResponse(request, template, context) def serve_preview(self, request, mode_name): view, args, kwargs = self.resolve_subpage("/") return view(request, *args, **kwargs) class RoutablePage(RoutablePageMixin, Page): """ This class extends Page by adding methods which allows extra routes to be added to it. """ class Meta: abstract = True